Compare commits

..

1 Commits

139 changed files with 7858 additions and 17728 deletions

View File

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

View File

@ -28,7 +28,8 @@
},
"plugins": [
"react",
"@typescript-eslint"
"@typescript-eslint",
"import"
],
"rules": {
"brace-style": [
@ -42,6 +43,41 @@
"SwitchCase": 1
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never",
"tsx": "never",
"js": "never"
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": true
}
],
"import/order": [
"error",
{
"groups": [
"builtin", // Built-in imports (come from NodeJS native) go first
"external", // <- External imports
"internal", // <- Absolute imports
["sibling", "parent"], // <- Relative imports, the sibling and parent types they can be mingled together
"index", // <- index imports
"unknown"
],
"newlines-between": "never",
"alphabetize": {
/* sort in ascending order. Options: ["ignore", "asc", "desc"] */
"order": "asc",
/* ignore case. Options: [true, false] */
"caseInsensitive": true
}
}
],
"jsx-one-expression-per-line": "off",
"max-len": "off",
"no-console": "off",
@ -78,6 +114,15 @@
"quotes": [
"error",
"double"
],
"sort-imports": [
"error",
{
"ignoreCase": false,
"ignoreDeclarationSort": true,
"ignoreMemberSort": true,
"allowSeparatedGroups": false
}
]
},
"settings": {

6820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.90",
"@kingsrook/qqq-frontend-core": "1.0.82",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -42,7 +42,6 @@
"react-dom": "18.0.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",

10
pom.xml
View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.20.0-SNAPSHOT</revision>
<revision>0.19.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.20.0-20240308.165846-65</version>
<version>0.17.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -77,13 +77,13 @@
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.15.0</version>
<version>4.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.6.2</version>
<version>5.4.1</version>
<scope>test</scope>
</dependency>
<dependency>
@ -119,7 +119,7 @@
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
<version>20230227</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@ -36,7 +36,7 @@ import {LicenseInfo} from "@mui/x-license-pro";
import jwt_decode from "jwt-decode";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Navigate, Route, Routes, useLocation,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
import CommandMenu from "CommandMenu";
import QContext from "QContext";
@ -73,14 +73,6 @@ export default function App()
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
/////////////////////////////////////////////////////////
Client.setUnauthorizedCallback(() =>
{
logout();
})
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
@ -175,8 +167,18 @@ export default function App()
console.log("Using existing sessionUUID cookie");
}
/*
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - this is our quick rollback plan - if we feel the need to stop using the cookie approach. //
// we turn off the shouldStoreNewToken block above, and turn on these 2 lines. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
localStorage.removeItem("accessToken");
*/
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user);
console.log("Token load complete.");
@ -197,8 +199,8 @@ export default function App()
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`));
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
@ -226,7 +228,6 @@ export default function App()
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]);
@ -355,7 +356,7 @@ export default function App()
routeList.push({
name: `${app.label}`,
key: app.name,
route: `${path}/savedView/:id`,
route: `${path}/savedFilter/:id`,
component: <RecordQuery table={table} key={table.name} />,
});
@ -518,7 +519,7 @@ export default function App()
name: loggedInUser?.name ?? "Anonymous",
key: "username",
noCollapse: true,
icon: <Avatar src={profilePicture} alt="{loggedInUser?.name}" />,
icon: <Avatar src={profilePicture} alt="{user?.name}" />,
};
setProfileRoutes(profileRoutes);
@ -656,28 +657,22 @@ 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);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
return (
appRoutes && (
<QContext.Provider value={{
pageHeader: pageHeader,
accentColor: accentColor,
accentColorLight: accentColorLight,
tableMetaData: tableMetaData,
tableProcesses: tableProcesses,
dotMenuOpen: dotMenuOpen,
keyboardHelpOpen: keyboardHelpOpen,
helpHelpActive: helpHelpActive,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),

View File

@ -36,7 +36,7 @@ import Icon from "@mui/material/Icon";
import Typography from "@mui/material/Typography";
import {makeStyles} from "@mui/styles";
import {Command} from "cmdk";
import React, {useContext, useEffect, useRef} from "react";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router-dom";
import QContext from "QContext";
import HistoryUtils, {QHistoryEntry} from "qqq/utils/HistoryUtils";
@ -174,9 +174,7 @@ const CommandMenu = ({metaData}: Props) =>
})
tableNames = tableNames.sort((a: string, b:string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return (labelA.localeCompare(labelB));
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
const path = location.pathname;
@ -224,9 +222,7 @@ const CommandMenu = ({metaData}: Props) =>
})
tableNames = tableNames.sort((a: string, b:string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return (labelA.localeCompare(labelB));
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
return(
<Command.Group heading="Tables">
@ -256,9 +252,7 @@ const CommandMenu = ({metaData}: Props) =>
appNames = appNames.sort((a: string, b:string) =>
{
const labelA = getFullAppLabel(metaData.appTree, a, 1, "") ?? "";
const labelB = getFullAppLabel(metaData.appTree, b, 1, "") ?? "";
return (labelA.localeCompare(labelB));
return (getFullAppLabel(metaData.appTree, a, 1, "").localeCompare(getFullAppLabel(metaData.appTree, b, 1, "")));
})
return(
@ -292,9 +286,7 @@ const CommandMenu = ({metaData}: Props) =>
appNames = appNames.sort((a: string, b:string) =>
{
const labelA = metaData.apps.get(a).label ?? "";
const labelB = metaData.apps.get(b).label ?? "";
return (labelA.localeCompare(labelB));
return (metaData.apps.get(a).label.localeCompare(metaData.apps.get(b).label));
})
const entryMap = new Map<string, boolean>();
@ -362,7 +354,8 @@ const CommandMenu = ({metaData}: Props) =>
<Grid container columnSpacing={5} rowSpacing={1}>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>n</span>Create a New Record</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>r</span>Refresh the Query</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Builder (Advanced mode only)</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>c</span>Open the Columns Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Panel</Grid>
</Grid>
<Typography variant="h6" pt={3}>Record View</Typography>

View File

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

10
src/main/java/Placeholder.java Executable file
View File

@ -0,0 +1,10 @@
/*******************************************************************************
** Placeholder class, because maven really wants some source under src/main?
*******************************************************************************/
public class Placeholder
{
public void f()
{
}
}

View File

@ -1,153 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QSupplementalAppMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** app-level meta-data for this module (handled as QSupplementalTableMetaData)
*******************************************************************************/
public class MaterialDashboardAppMetaData extends QSupplementalAppMetaData
{
public static final String TYPE_NAME = "materialDashboard";
private Boolean showAppLabelOnHomeScreen = true;
private Boolean includeTableCountsOnHomeScreen = true;
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardAppMetaData of(QAppMetaData app)
{
return ((MaterialDashboardAppMetaData) CollectionUtils.nonNullMap(app.getSupplementalMetaData()).get(TYPE_NAME));
}
/*******************************************************************************
** either get the supplemental meta dat attached to an app - or create a new one
** and attach it to the app, and return that.
*******************************************************************************/
public static MaterialDashboardAppMetaData ofOrWithNew(QAppMetaData app)
{
MaterialDashboardAppMetaData materialDashboardAppMetaData = of(app);
if(materialDashboardAppMetaData == null)
{
materialDashboardAppMetaData = new MaterialDashboardAppMetaData();
app.withSupplementalMetaData(materialDashboardAppMetaData);
}
return (materialDashboardAppMetaData);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean includeInFullFrontendMetaData()
{
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return TYPE_NAME;
}
/*******************************************************************************
** Getter for showAppLabelOnHomeScreen
*******************************************************************************/
public Boolean getShowAppLabelOnHomeScreen()
{
return (this.showAppLabelOnHomeScreen);
}
/*******************************************************************************
** Setter for showAppLabelOnHomeScreen
*******************************************************************************/
public void setShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for showAppLabelOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
return (this);
}
/*******************************************************************************
** Getter for includeTableCountsOnHomeScreen
*******************************************************************************/
public Boolean getIncludeTableCountsOnHomeScreen()
{
return (this.includeTableCountsOnHomeScreen);
}
/*******************************************************************************
** Setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public void setIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
return (this);
}
}

View File

@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
/*******************************************************************************
**
*******************************************************************************/
public interface MaterialDashboardIconRoleNames
{
String TOP_RIGHT_INSIDE_CARD = "topRightInsideCard";
String TOP_LEFT_INSIDE_CARD = "topLeftInsideCard";
}

View File

@ -22,23 +22,17 @@
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
**
*******************************************************************************/
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{
private List<List<String>> gotoFieldNames;
private List<String> defaultQuickFilterFieldNames;
/*******************************************************************************
@ -92,73 +86,4 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator)
{
super.validate(qInstance, tableMetaData, qInstanceValidator);
String prefix = "MaterialDashboardTableMetaData supplementalTableMetaData for table [" + tableMetaData.getName() + "] ";
for(List<String> gotoFieldNameSubList : CollectionUtils.nonNullList(gotoFieldNames))
{
qInstanceValidator.assertCondition(!gotoFieldNameSubList.isEmpty(), prefix + "has an empty gotoFieldNames list");
validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: ");
}
validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: ");
}
/*******************************************************************************
**
*******************************************************************************/
private void validateListOfFieldNames(QTableMetaData tableMetaData, List<String> fieldNames, QInstanceValidator qInstanceValidator, String prefix)
{
Set<String> usedNames = new HashSet<>();
for(String fieldName : CollectionUtils.nonNullList(fieldNames))
{
if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName))
{
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName);
usedNames.add(fieldName);
}
}
}
/*******************************************************************************
** Getter for defaultQuickFilterFieldNames
*******************************************************************************/
public List<String> getDefaultQuickFilterFieldNames()
{
return (this.defaultQuickFilterFieldNames);
}
/*******************************************************************************
** Setter for defaultQuickFilterFieldNames
*******************************************************************************/
public void setDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
{
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
}
/*******************************************************************************
** Fluent setter for defaultQuickFilterFieldNames
*******************************************************************************/
public MaterialDashboardTableMetaData withDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
{
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
return (this);
}
}

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -78,19 +78,6 @@ interface Types
light: string;
main: string;
focus: string;
}
blueGray:
| {
main: string;
}
gray:
| {
main: string;
focus: string;
}
grayLines:
| {
main: string;
}
| any;
primary: ColorsTypes | any;
@ -187,19 +174,6 @@ const colors: Types = {
focus: "#ffffff",
},
blueGray: {
main: "#546E7A"
},
gray: {
main: "#757575",
focus: "#757575",
},
grayLines: {
main: "#D6D6D6"
},
black: {
light: "#000000",
main: "#000000",
@ -242,7 +216,7 @@ const colors: Types = {
},
dark: {
main: "#212121",
main: "#344767",
focus: "#2c3c58",
},

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,
@ -199,10 +199,9 @@ const typography: Types = {
},
h3: {
fontSize: "1.75rem",
fontSize: pxToRem(30),
lineHeight: 1.375,
...baseHeadingProperties,
fontWeight: 600
},
h4: {
@ -218,10 +217,9 @@ const typography: Types = {
},
h6: {
fontSize: "1.125rem",
fontSize: pxToRem(16),
lineHeight: 1.625,
...baseHeadingProperties,
fontWeight: 500
},
subtitle1: {

View File

@ -31,7 +31,7 @@ type Types = any;
const card: Types = {
defaultProps: {
elevation: 0
elevation: 3
},
styleOverrides: {
root: {
@ -42,7 +42,7 @@ const card: Types = {
wordWrap: "break-word",
backgroundColor: white.main,
backgroundClip: "border-box",
border: `${borderWidth[1]} solid ${colors.grayLines.main}`,
border: `${borderWidth[0]} solid ${rgba(black.main, 0.125)}`,
borderRadius: borderRadius.xl,
overflow: "visible",
},

View File

@ -15,11 +15,15 @@
// Material Dashboard 2 PRO React TS Base Styles
import colors from "qqq/assets/theme/base/colors";
import borders from "qqq/assets/theme/base/borders";
import boxShadows from "qqq/assets/theme/base/boxShadows";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const { grey, white } = colors;
const { borderRadius } = borders;
const { tabsBoxShadow } = boxShadows;
// types
type Types = any;
@ -28,25 +32,15 @@ const tabs: Types = {
styleOverrides: {
root: {
position: "relative",
borderRadius: 0,
borderBottom: "1px solid",
borderBottomColor: grey[400],
backgroundColor: grey[100],
borderRadius: borderRadius.xl,
minHeight: "unset",
padding: "0",
margin: "0",
"& button": {
fontWeight: 500
}
},
scroller: {
marginLeft: "0.5rem"
padding: pxToRem(4),
},
flexContainer: {
height: "100%",
position: "relative",
width: "fit-content",
zIndex: 10,
},
@ -63,10 +57,9 @@ const tabs: Types = {
indicator: {
height: "100%",
borderRadius: 0,
borderRadius: borderRadius.lg,
backgroundColor: white.main,
borderBottom: "2px solid",
borderBottomColor: colors.info.main,
boxShadow: tabsBoxShadow.indicator,
transition: "all 500ms ease",
},
},

View File

@ -43,10 +43,8 @@ const tab: Types = {
fontWeight: fontWeightRegular,
textTransform: "none",
lineHeight: "inherit",
padding: "0.75rem 0.5rem 0.5rem",
margin: "0 0.5rem",
borderRadius: 0,
border: 0,
padding: pxToRem(4),
borderRadius: borderRadius.lg,
color: `${dark.main} !important`,
opacity: "1 !important",

View File

@ -24,7 +24,7 @@ import borders from "qqq/assets/theme/base/borders";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const { black, light, white, dark } = colors;
const { black, light } = colors;
const { size, fontWeightRegular } = typography;
const { borderRadius } = borders;
@ -39,20 +39,19 @@ const tooltip: Types = {
styleOverrides: {
tooltip: {
maxWidth: pxToRem(300),
backgroundColor: white.main,
color: dark.main,
maxWidth: pxToRem(200),
backgroundColor: black.main,
color: light.main,
fontSize: size.sm,
fontWeight: fontWeightRegular,
textAlign: "left",
textAlign: "center",
borderRadius: borderRadius.md,
opacity: 0.7,
padding: "1rem",
boxShadow: "0px 0px 12px rgba(128, 128, 128, 0.40)"
padding: `${pxToRem(5)} ${pxToRem(8)} ${pxToRem(4)}`,
},
arrow: {
color: white.main,
color: black.main,
},
},
};

View File

@ -34,7 +34,7 @@ import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import React, {useContext, useEffect, useState} from "react";
import React, {JSXElementConstructor, useContext, useEffect, useState} from "react";
import QContext from "QContext";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -58,19 +58,19 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const [limit, setLimit] = useState(1000);
const [statusString, setStatusString] = useState("Loading audits...");
const [auditsByDate, setAuditsByDate] = useState([] as QRecord[][]);
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>);
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>);
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>)
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>)
const [sortDirection, setSortDirection] = useState(localStorage.getItem("audit.sortDirection") === "true");
const {accentColor} = useContext(QContext);
function wrapValue(value: any): JSX.Element
{
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>;
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>
}
function wasValue(value: any): JSX.Element
{
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>;
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>
}
function getAuditDetailFieldChangeRow(qRecord: QRecord): JSX.Element | null
@ -81,12 +81,8 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const newValue = qRecord.values.get("auditDetail.newValue");
if(fieldName && (oldValue !== null || newValue !== null))
{
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
return (<tr>
<td>{fieldLabel}</td>
<td>{oldValue}</td>
<td>{newValue}</td>
</tr>);
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName
return (<tr><td>{fieldLabel}</td><td>{oldValue}</td><td>{newValue}</td></tr>)
}
return (null);
}
@ -202,12 +198,12 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
new QFilterOrderBy("timestamp", sortDirection),
new QFilterOrderBy("id", sortDirection),
new QFilterOrderBy("auditDetail.id", true)
], null, "AND", 0, limit);
], "AND", 0, limit);
///////////////////////////////
// fetch audits in try-catch //
///////////////////////////////
let audits = [] as QRecord[];
let audits = [] as QRecord[]
try
{
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
@ -237,7 +233,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// group the audits by auditId (e.g., this is a list that joined audit & auditDetail, so un-flatten it) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
const unflattenedAudits: QRecord[] = [];
const unflattenedAudits: QRecord[] = []
const detailMap: Map<number, JSX.Element[]> = new Map();
const fieldChangeRowsMap: Map<number, JSX.Element[]> = new Map();
for (let i = 0; i < audits.length; i++)
@ -256,7 +252,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
detailMap.set(id, []);
}
detailMap.get(id).push(auditDetail);
detailMap.get(id).push(auditDetail)
}
// table version, probably not to commit
@ -292,11 +288,11 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
{fieldChangeRowsMap.get(id).map((row, key) => <React.Fragment key={key}>{row}</React.Fragment>)}
</tbody>
</table>
);
)
fieldChangeMap.set(id, fieldChangeTable);
}
}
setFieldChangeMap(fieldChangeMap);
setFieldChangeMap(fieldChangeMap)
//////////////////////////////
// group the audits by date //
@ -354,7 +350,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const changeSortDirection = () =>
{
setAudits([]);
const newSortDirection = !sortDirection;
const newSortDirection = !sortDirection
setSortDirection(newSortDirection);
localStorage.setItem("audit.sortDirection", String(newSortDirection));
};
@ -406,8 +402,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
return (
<Box key={audit0.values.get("id")} className="auditGroupBlock">
<Box position="sticky" top="0" zIndex={3}>
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14} position={"relative"} top={"-1px"} pb={"6px"} sx={{backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 80%, rgba(255,255,255,0))"}}>
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
@ -416,7 +411,6 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
</Box>
</Box>
{
audits.map((audit) =>

View File

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

View File

@ -19,18 +19,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Box, InputLabel} from "@mui/material";
import {InputLabel} from "@mui/material";
import Stack from "@mui/material/Stack";
import {styled} from "@mui/material/styles";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import {useFormikContext} from "formik";
import React, {SyntheticEvent} from "react";
import colors from "qqq/assets/theme/base/colors";
const AntSwitch = styled(Switch)(({theme}) => ({
width: 32,
height: 20,
width: 28,
height: 16,
padding: 0,
display: "flex",
"&:active": {
@ -54,19 +53,15 @@ const AntSwitch = styled(Switch)(({theme}) => ({
},
"& .MuiSwitch-thumb": {
boxShadow: "0 2px 4px 0 rgb(0 35 11 / 20%)",
width: 16,
height: 16,
borderRadius: 8,
width: 12,
height: 12,
borderRadius: 6,
transition: theme.transitions.create([ "width" ], {
duration: 200,
}),
},
"&.nullSwitch .MuiSwitch-thumb": {
width: 28,
},
"& .MuiSwitch-track": {
height: 20,
borderRadius: 20 / 2,
borderRadius: 16 / 2,
opacity: 1,
backgroundColor:
theme.palette.mode === "dark" ? "rgba(255,255,255,.35)" : "rgba(0,0,0,.25)",
@ -83,7 +78,6 @@ interface Props
}
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -102,29 +96,27 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
setFieldValue(name, !value);
}
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
return (
<Box bgcolor={isDisabled ? colors.grey[200] : ""}>
<>
<InputLabel shrink={true}>{label}</InputLabel>
<Stack direction="row" spacing={1} alignItems="center" height="37px">
<Stack direction="row" spacing={1} alignItems="center">
<Typography
fontSize="1rem"
fontSize="0.875rem"
color={value === false ? "auto" : "#bfbfbf" }
onClick={(e) => setSwitch(e, false)}
sx={{cursor: value === false || isDisabled ? "inherit" : "pointer"}}>
No
</Typography>
<AntSwitch className={classNullSwitch} name={name} checked={value} onClick={toggleSwitch} disabled={isDisabled} />
<AntSwitch name={name} checked={value} onClick={toggleSwitch} disabled={isDisabled} />
<Typography
fontSize="1rem"
fontSize="0.875rem"
color={value === true ? "auto" : "#bfbfbf"}
onClick={(e) => setSwitch(e, true)}
sx={{cursor: value === true || isDisabled ? "inherit" : "pointer"}}>
Yes
</Typography>
</Stack>
</Box>
</>
);
}

View File

@ -32,7 +32,6 @@ import React, {useState} from "react";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface Props
@ -42,13 +41,16 @@ interface Props
bulkEditMode?: boolean;
bulkEditSwitchChangeHandler?: any;
record?: QRecord;
helpRoles?: string[];
helpContentKeyPrefix?: string;
}
function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler, record, helpRoles, helpContentKeyPrefix}: Props): JSX.Element
function QDynamicForm(props: Props): JSX.Element
{
const {formFields, values, errors, touched} = formData;
const {
formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler,
} = props;
const {
formFields, values, errors, touched,
} = formData;
const formikProps = useFormikContext();
const [fileName, setFileName] = useState(null as string);
@ -68,8 +70,8 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName)
record?.displayValues.delete(fieldName)
props.record?.values.delete(fieldName)
props.record?.displayValues.delete(fieldName)
};
const bulkEditSwitchChanged = (name: string, value: boolean) =>
@ -77,7 +79,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
bulkEditSwitchChangeHandler(name, value);
};
return (
<Box>
<Box lineHeight={0}>
@ -95,38 +96,29 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
&& Object.keys(formFields).map((fieldName: any) =>
{
const field = formFields[fieldName];
if (field.omitFromQDynamicForm)
{
return null;
}
if (values[fieldName] === undefined)
{
values[fieldName] = "";
}
let formattedHelpContent = <HelpContent helpContents={field.fieldMetaData.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if(formattedHelpContent)
if (field.omitFromQDynamicForm)
{
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
return null;
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={field.name}>{field.label}</label>
</Box>
if (field.type === "file")
{
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
return (
<Grid item xs={12} sm={6} key={fieldName}>
<Box mb={1.5}>
{labelElement}
<InputLabel shrink={true}>{field.label}</InputLabel>
{
record && record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
props.record && props.record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
{ValueUtils.getDisplayValue(pseudoField, props.record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
</Tooltip>
@ -170,20 +162,18 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
return (
<Grid item xs={12} sm={6} key={fieldName}>
{labelElement}
<DynamicSelect
tableName={field.possibleValueProps.tableName}
processName={field.possibleValueProps.processName}
fieldName={fieldName}
isEditable={field.isEditable}
fieldLabel=""
fieldLabel={field.label}
initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap}
/>
{formattedHelpContent}
</Grid>
);
}
@ -192,11 +182,9 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
// todo? placeholder={password.placeholder}
return (
<Grid item xs={12} sm={6} key={fieldName}>
{labelElement}
<QDynamicFormField
id={field.name}
type={field.type}
label=""
label={field.label}
isEditable={field.isEditable}
name={fieldName}
displayFormat={field.displayFormat}
@ -207,7 +195,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
success={`${values[fieldName]}` !== "" && !errors[fieldName] && touched[fieldName]}
formFieldObject={field}
/>
{formattedHelpContent}
</Grid>
);
})}
@ -220,7 +207,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
QDynamicForm.defaultProps = {
formLabel: undefined,
bulkEditMode: false,
helpRoles: ["ALL_SCREENS"],
bulkEditSwitchChangeHandler: () =>
{
},

View File

@ -25,7 +25,6 @@ import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import React, {useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
@ -53,7 +52,6 @@ function QDynamicFormField({
{
const [switchChecked, setSwitchChecked] = useState(false);
const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode);
const {inputBorderColor} = colors;
const {setFieldValue} = useFormikContext();
@ -90,14 +88,7 @@ function QDynamicFormField({
if (type === "checkbox")
{
getsBulkEditHtmlLabel = false;
field = (<>
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
</>);
field = (<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />);
}
else if (type === "ace")
{
@ -124,7 +115,7 @@ function QDynamicFormField({
width="100%"
height="300px"
value={value}
style={{border: `1px solid ${inputBorderColor}`, borderRadius: "0.75rem"}}
style={{border: "1px solid gray"}}
/>
</>
);
@ -133,7 +124,7 @@ function QDynamicFormField({
{
field = (
<>
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="standard" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
onKeyPress={(e: any) =>
{
if (e.key === "Enter")
@ -173,14 +164,6 @@ function QDynamicFormField({
id={`bulkEditSwitch-${name}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
top: -3,
position: "relative"
}
}}
/>
</Box>
<Box width="100%" sx={{background: (type == "checkbox" && isDisabled) ? "#f0f2f5!important" : "initial"}}>

View File

@ -24,38 +24,27 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import * as Yup from "yup";
type DisabledFields = { [fieldName: string]: boolean } | string[];
/*******************************************************************************
** Meta-data to represent a single field in a table.
**
*******************************************************************************/
class DynamicFormUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static getFormData(qqqFormFields: QFieldMetaData[], disabledFields?: DisabledFields)
public static getFormData(qqqFormFields: QFieldMetaData[])
{
const dynamicFormFields: any = {};
const formValidations: any = {};
qqqFormFields.forEach((field) =>
{
dynamicFormFields[field.name] = this.getDynamicField(field, disabledFields);
formValidations[field.name] = this.getValidationForField(field, disabledFields);
dynamicFormFields[field.name] = this.getDynamicField(field);
formValidations[field.name] = this.getValidationForField(field);
});
return {dynamicFormFields, formValidations};
}
/*******************************************************************************
**
*******************************************************************************/
public static getDynamicField(field: QFieldMetaData, disabledFields?: DisabledFields)
public static getDynamicField(field: QFieldMetaData)
{
let fieldType: string;
switch (field.type.toString())
@ -96,21 +85,14 @@ class DynamicFormUtils
}
}
////////////////////////////////////////////////////////////
// this feels right, but... might be cases where it isn't //
////////////////////////////////////////////////////////////
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
let label = field.label ? field.label : field.name;
label += effectivelyIsRequired ? " *" : "";
label += field.isRequired ? " *" : "";
return ({
fieldMetaData: field,
name: field.name,
label: label,
isRequired: effectivelyIsRequired,
isEditable: effectiveIsEditable,
isRequired: field.isRequired,
isEditable: field.isEditable,
type: fieldType,
displayFormat: field.displayFormat,
// todo invalidMsg: "Zipcode is not valid (e.g. 70000).",
@ -118,16 +100,9 @@ class DynamicFormUtils
});
}
/*******************************************************************************
**
*******************************************************************************/
public static getValidationForField(field: QFieldMetaData, disabledFields?: DisabledFields)
public static getValidationForField(field: QFieldMetaData)
{
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
if (effectivelyIsRequired)
if (field.isRequired)
{
if(field.possibleValueSourceName)
{
@ -145,10 +120,6 @@ class DynamicFormUtils
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
public static addPossibleValueProps(dynamicFormFields: any, qFields: QFieldMetaData[], tableName: string, processName: string, displayValues: Map<string, string>)
{
for (let i = 0; i < qFields.length; i++)
@ -187,29 +158,6 @@ class DynamicFormUtils
}
}
}
/*******************************************************************************
** private helper - check the disabled fields object (array or map), and return
** true iff fieldName is in it.
*******************************************************************************/
private static isFieldDynamicallyDisabled(fieldName: string, disabledFields?: DisabledFields): boolean
{
if (!disabledFields)
{
return (false);
}
if (Array.isArray(disabledFields))
{
return (disabledFields.indexOf(fieldName) > -1)
}
else
{
return (disabledFields[fieldName]);
}
}
}
export default DynamicFormUtils;

View File

@ -29,7 +29,6 @@ import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import {ErrorMessage, useFormikContext} from "formik";
import React, {useEffect, useState} from "react";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
import Client from "qqq/utils/qqq/Client";
@ -50,8 +49,6 @@ interface Props
bulkEditMode?: boolean;
bulkEditSwitchChangeHandler?: any;
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
}
DynamicSelect.defaultProps = {
@ -66,8 +63,6 @@ DynamicSelect.defaultProps = {
isMultiple: false,
bulkEditMode: false,
otherValues: new Map<string, any>(),
variant: "outlined",
initiallyOpen: false,
bulkEditSwitchChangeHandler: () =>
{
},
@ -75,14 +70,12 @@ DynamicSelect.defaultProps = {
const qController = Client.getInstance();
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props)
{
const [open, setOpen] = useState(initiallyOpen);
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
const [searchTerm, setSearchTerm] = useState(null);
const [firstRender, setFirstRender] = useState(true);
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))))
const {inputBorderColor} = colors;
////////////////////////////////////////////////////////////////////////////////////////////////
// default value - needs to be an array (from initialValues (array) prop) for multiple mode - //
@ -116,15 +109,8 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{
// console.log("First render, so not searching...");
setFirstRender(false);
/*
if(!initiallyOpen)
{
console.log("returning because not initially open?");
return;
}
*/
}
// console.log("Use effect for searchTerm - searching!");
let active = true;
@ -156,24 +142,6 @@ 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}`);
@ -262,7 +230,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
// attributes. so, doing this, w/ key=id, seemed to fix it. //
///////////////////////////////////////////////////////////////////////////////////////////////
return (
<li {...props} key={option.id} style={{fontSize: "1rem"}}>
<li {...props} key={option.id}>
{content}
</li>
);
@ -276,35 +244,13 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
bulkEditSwitchChangeHandler(fieldName, newSwitchValue);
};
////////////////////////////////////////////
// for outlined style, adjust some styles //
////////////////////////////////////////////
let autocompleteSX = {};
if (variant == "outlined")
{
autocompleteSX = {
"& .MuiOutlinedInput-root": {
borderRadius: "0.75rem",
},
"& .MuiInputBase-root": {
padding: "0.5rem",
background: isDisabled ? "#f0f2f5!important" : "initial",
},
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
padding: "0",
fontSize: "1rem"
},
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: inputBorderColor
}
}
}
// console.log(`default value: ${JSON.stringify(defaultValue)}`);
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName}
sx={autocompleteSX}
sx={{background: isDisabled ? "#f0f2f5!important" : "initial"}}
open={open}
fullWidth
onOpen={() =>
@ -321,14 +267,9 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{
setOpen(false);
}}
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) =>
{
if(option === null || option === undefined)
{
return ("");
}
// @ts-ignore
if(option && option.length)
{
@ -364,7 +305,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
<TextField
{...params}
label={fieldLabel}
variant={variant}
variant="standard"
autoComplete="off"
type="search"
InputProps={{
@ -400,14 +341,6 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
id={`bulkEditSwitch-${fieldName}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
top: -3,
position: "relative"
}
}}
/>
</Box>
<Box width="100%">

View File

@ -22,10 +22,8 @@
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert} from "@mui/material";
@ -34,24 +32,20 @@ import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import {Form, Formik, FormikErrors, FormikTouched, FormikValues, useFormikContext} from "formik";
import {Form, Formik, useFormikContext} from "formik";
import React, {useContext, useEffect, useReducer, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom";
import * as Yup from "yup";
import QContext from "QContext";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useReducer, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom";
import {Value} from "sass";
import * as Yup from "yup";
interface Props
{
@ -62,8 +56,6 @@ interface Props
defaultValues: { [key: string]: string };
disabledFields: { [key: string]: boolean } | string[];
isCopy?: boolean;
onSubmitCallback?: (values: any) => void;
overrideHeading?: string
}
EntityForm.defaultProps = {
@ -73,8 +65,7 @@ EntityForm.defaultProps = {
closeModalHandler: null,
defaultValues: {},
disabledFields: {},
isCopy: false,
onSubmitCallback: null,
isCopy: false
};
function EntityForm(props: Props): JSX.Element
@ -88,7 +79,6 @@ function EntityForm(props: Props): JSX.Element
const [validations, setValidations] = useState({});
const [initialValues, setInitialValues] = useState({} as { [key: string]: any });
const [formFields, setFormFields] = useState(null as Map<string, any>);
const [t1section, setT1Section] = useState(null as QTableSection);
const [t1sectionName, setT1SectionName] = useState(null as string);
const [nonT1Sections, setNonT1Sections] = useState([] as QTableSection[]);
@ -97,15 +87,10 @@ function EntityForm(props: Props): JSX.Element
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [metaData, setMetaData] = useState(null as QInstance);
const [record, setRecord] = useState(null as QRecord);
const [tableSections, setTableSections] = useState(null as QTableSection[]);
const [renderedWidgetSections, setRenderedWidgetSections] = useState({} as {[name: string]: JSX.Element});
const [childListWidgetData, setChildListWidgetData] = useState({} as {[name: string]: ChildRecordListData});
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
const [notAllowedError, setNotAllowedError] = useState(null as string);
const {pageHeader, setPageHeader} = useContext(QContext);
@ -113,8 +98,6 @@ function EntityForm(props: Props): JSX.Element
const navigate = useNavigate();
const location = useLocation();
const cardElevation = props.isModal ? 3 : 0;
////////////////////////////////////////////////////////////////////
// first take defaultValues and disabledFields from props //
// but, also allow them to be sent in the hash, in the format of: //
@ -143,131 +126,7 @@ function EntityForm(props: Props): JSX.Element
{}
}
/*******************************************************************************
**
*******************************************************************************/
function openAddChildRecord(name: string, widgetData: any)
{
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if(!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function openEditChildRecord(name: string, widgetData: any, rowIndex: number)
{
let defaultValues = widgetData.queryOutput.records[rowIndex].values;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if(!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, rowIndex, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
const deleteChildRecord = (name: string, widgetData: any, rowIndex: number) =>
{
updateChildRecordList(name, "delete", rowIndex);
};
/*******************************************************************************
**
*******************************************************************************/
function doOpenEditChildForm(widgetName: string, table: QTableMetaData, rowIndex: number, defaultValues: any, disabledFields: any)
{
const showEditChildForm: any = {};
showEditChildForm.widgetName = widgetName;
showEditChildForm.table = table;
showEditChildForm.rowIndex = rowIndex;
showEditChildForm.defaultValues = defaultValues;
showEditChildForm.disabledFields = disabledFields;
setShowEditChildForm(showEditChildForm);
}
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowEditChildForm(null);
};
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
}
/*******************************************************************************
**
*******************************************************************************/
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
{
const metaData = await qController.loadMetaData();
const widgetMetaData = metaData.widgets.get(widgetName);
const newChildListWidgetData: {[name: string]: ChildRecordListData} = Object.assign({}, childListWidgetData)
if(!newChildListWidgetData[widgetName].queryOutput.records)
{
newChildListWidgetData[widgetName].queryOutput.records = [];
}
switch(action)
{
case "insert":
newChildListWidgetData[widgetName].queryOutput.records.push({values: values})
break;
case "edit":
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values};
break;
case "delete":
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
break;
}
newChildListWidgetData[widgetName].totalRows = newChildListWidgetData[widgetName].queryOutput.records.length;
setChildListWidgetData(newChildListWidgetData);
const newRenderedWidgetSections = Object.assign({}, renderedWidgetSections)
newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, newChildListWidgetData[widgetName]);
setRenderedWidgetSections(newRenderedWidgetSections);
forceUpdate();
setShowEditChildForm(null);
}
/*******************************************************************************
** render a section (full of fields) as a form
*******************************************************************************/
function getFormSection(section: QTableSection, values: any, touched: any, formFields: any, errors: any, omitWrapper = false): JSX.Element
function getFormSection(values: any, touched: any, formFields: any, errors: any): JSX.Element
{
const formData: any = {};
formData.values = values;
@ -290,68 +149,11 @@ function EntityForm(props: Props): JSX.Element
if (!Object.keys(formFields).length)
{
return <div>Error: No form fields in section {section.name}</div>;
return <div>Loading...</div>;
}
const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
if(omitWrapper)
{
return <QDynamicForm formData={formData} record={record} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableName};`} />
return <QDynamicForm formData={formData} record={record} />;
}
return <Card id={section.name} sx={{overflow: "visible", scrollMarginTop: "100px"}} elevation={cardElevation}>
<MDTypography variant="h6" p={3} pb={1}>
{section.label}
</MDTypography>
{getSectionHelp(section)}
<Box pb={1} px={3}>
<Box pb={"0.75rem"} width="100%">
<QDynamicForm formData={formData} record={record} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableName};`} />
</Box>
</Box>
</Card>
}
/*******************************************************************************
** render a section as a widget
*******************************************************************************/
function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element
{
widgetData.viewAllLink = null;
widgetMetaData.showExportButton = false;
return <RecordGridWidget
key={new Date().getTime()} // added so that editing values actually re-renders...
widgetMetaData={widgetMetaData}
data={widgetData}
disableRowClick
allowRecordEdit
allowRecordDelete
addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)}
/>
}
/*******************************************************************************
** render a form section
*******************************************************************************/
function renderSection(section: QTableSection, values: FormikValues | Value, touched: FormikTouched<FormikValues> | Value, formFields: Map<string, any>, errors: FormikErrors<FormikValues> | Value)
{
if(section.fieldNames && section.fieldNames.length > 0)
{
return getFormSection(section, values, touched, formFields.get(section.name), errors);
}
else
{
return renderedWidgetSections[section.widgetName] ?? <Box>Loading {section.label}...</Box>
}
}
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
@ -360,16 +162,10 @@ function EntityForm(props: Props): JSX.Element
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
const metaData = await qController.loadMetaData();
setMetaData(metaData);
/////////////////////////////////////////////////
// define the sections, e.g., for the left-bar //
/////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
{
return section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList" && metaData.widgets.get(section.widgetName)?.defaultValues?.has("manageAssociationName")
});
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()]);
setTableSections(tableSections);
const fieldArray = [] as QFieldMetaData[];
@ -462,18 +258,6 @@ function EntityForm(props: Props): JSX.Element
}
}
///////////////////////////////////////////////////
// if an override heading was passed in, use it. //
///////////////////////////////////////////////////
if(props.overrideHeading)
{
setFormTitle(props.overrideHeading);
if (!props.isModal)
{
setPageHeader(props.overrideHeading);
}
}
//////////////////////////////////////
// check capabilities & permissions //
//////////////////////////////////////
@ -520,38 +304,43 @@ function EntityForm(props: Props): JSX.Element
const {
dynamicFormFields,
formValidations,
} = DynamicFormUtils.getFormData(fieldArray, disabledFields);
} = DynamicFormUtils.getFormData(fieldArray);
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues);
if(disabledFields)
{
if(Array.isArray(disabledFields))
{
for (let i = 0; i < disabledFields.length; i++)
{
dynamicFormFields[disabledFields[i]].isEditable = false;
}
}
else
{
for (let fieldName in disabledFields)
{
dynamicFormFields[fieldName].isEditable = false;
}
}
}
/////////////////////////////////////
// group the formFields by section //
/////////////////////////////////////
const dynamicFormFieldsBySection = new Map<string, any>();
let t1sectionName;
let t1section;
const nonT1Sections: QTableSection[] = [];
const newRenderedWidgetSections: {[name: string]: JSX.Element} = {};
const newChildListWidgetData: {[name: string]: ChildRecordListData} = {};
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
const sectionDynamicFormFields: any[] = [];
if (section.isHidden)
if (section.isHidden || !section.fieldNames)
{
continue;
}
const hasFields = section.fieldNames && section.fieldNames.length > 0;
const hasChildRecordListWidget = section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList"
if(!hasFields && !hasChildRecordListWidget)
{
continue;
}
if(hasFields)
{
for (let j = 0; j < section.fieldNames.length; j++)
{
const fieldName = section.fieldNames[j];
@ -586,59 +375,28 @@ function EntityForm(props: Props): JSX.Element
{
dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields);
}
}
else
{
const widgetMetaData = metaData.widgets.get(section.widgetName);
const widgetData = await qController.widget(widgetMetaData.name, props.id ? `${tableMetaData.primaryKeyField}=${props.id}` : "");
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
newChildListWidgetData[section.widgetName] = widgetData;
}
//////////////////////////////////////
// capture the tier1 section's name //
//////////////////////////////////////
if (section.tier === "T1")
{
t1sectionName = section.name;
t1section = section;
}
else
{
nonT1Sections.push(section);
}
}
setT1SectionName(t1sectionName);
setT1Section(t1section);
setNonT1Sections(nonT1Sections);
setFormFields(dynamicFormFieldsBySection);
setValidations(Yup.object().shape(formValidations));
setRenderedWidgetSections(newRenderedWidgetSections);
setChildListWidgetData(newChildListWidgetData);
forceUpdate();
})();
}
//////////////////////////////////////////////////////////////////
// watch widget data - if they change, re-render those sections //
//////////////////////////////////////////////////////////////////
useEffect(() =>
{
if(childListWidgetData)
{
const newRenderedWidgetSections: {[name: string]: JSX.Element} = {};
for(let name in childListWidgetData)
{
const widgetMetaData = metaData.widgets.get(name);
newRenderedWidgetSections[name] = getWidgetSection(widgetMetaData, childListWidgetData[name]);
}
setRenderedWidgetSections(newRenderedWidgetSections);
}
}, [childListWidgetData]);
const handleCancelClicked = () =>
{
///////////////////////////////////////////////////////////////////////////////////////
@ -663,30 +421,11 @@ function EntityForm(props: Props): JSX.Element
}
};
/*******************************************************************************
** event handler for the (Formik) Form.
*******************************************************************************/
const handleSubmit = async (values: any, actions: any) =>
{
actions.setSubmitting(true);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there anre return. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(props.onSubmitCallback)
{
props.onSubmitCallback(values);
return;
}
await (async () =>
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// we will be manipulating the values sent to the backend, so clone values so they remained unchanged for the form widgets //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const valuesToPost = JSON.parse(JSON.stringify(values));
for(let fieldName of tableMetaData.fields.keys())
{
const fieldMetaData = tableMetaData.fields.get(fieldName);
@ -699,17 +438,17 @@ function EntityForm(props: Props): JSX.Element
// changing from, say, 12:15:30 to just 12:15:00... this seems to get around that, for cases when the //
// user didn't change the value in the field (but if the user did change the value, then we will submit it) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(fieldMetaData.type === QFieldType.DATE_TIME && valuesToPost[fieldName])
if(fieldMetaData.type === QFieldType.DATE_TIME && values[fieldName])
{
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${valuesToPost[fieldName]}]`)
if (initialValues[fieldName] == valuesToPost[fieldName])
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${values[fieldName]}]`)
if (initialValues[fieldName] == values[fieldName])
{
console.log(" - Is the same, so, deleting from the post");
delete (valuesToPost[fieldName]);
delete (values[fieldName]);
}
else
{
valuesToPost[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(valuesToPost[fieldName]);
values[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(values[fieldName]);
}
}
@ -722,48 +461,19 @@ function EntityForm(props: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(fieldMetaData.type === QFieldType.BLOB)
{
if(typeof valuesToPost[fieldName] === "string")
if(typeof values[fieldName] === "string")
{
console.log(`${fieldName} value was a string, so, we're deleting it from the values array, to not submit it to the backend, to not change it.`);
delete(valuesToPost[fieldName]);
}
else
{
valuesToPost[fieldName] = values[fieldName];
delete(values[fieldName]);
}
}
}
// todo - associations + copy might be a "bad time"
const associationsToPost: any = {}
let haveAssociationsToPost = false;
for (let name of Object.keys(childListWidgetData))
{
const manageAssociationName = metaData.widgets.get(name)?.defaultValues?.get("manageAssociationName")
if(!manageAssociationName)
{
console.log(`Cannot send association data to backend - missing a manageAssociationName defaultValue in widget meta data for widget name ${name}`);
}
associationsToPost[manageAssociationName] = [];
haveAssociationsToPost = true;
for(let i=0; i<childListWidgetData[name].queryOutput?.records?.length; i++)
{
associationsToPost[manageAssociationName].push(childListWidgetData[name].queryOutput.records[i].values);
}
}
if(haveAssociationsToPost)
{
valuesToPost["associations"] = JSON.stringify(associationsToPost);
}
if (props.id !== null && !props.isCopy)
{
///////////////////////
// perform an update //
///////////////////////
// todo - audit that it's a dupe
await qController
.update(tableName, props.id, valuesToPost)
.update(tableName, props.id, values)
.then((record) =>
{
if (props.isModal)
@ -772,14 +482,8 @@ function EntityForm(props: Props): JSX.Element
}
else
{
let warningMessage = null;
if(record.warnings && record.warnings.length && record.warnings.length > 0)
{
warningMessage = record.warnings[0];
}
const path = location.pathname.replace(/\/edit$/, "");
navigate(path, {state: {updateSuccess: true, warning: warningMessage}});
navigate(path, {state: {updateSuccess: true}});
}
})
.catch((error) =>
@ -801,12 +505,8 @@ function EntityForm(props: Props): JSX.Element
}
else
{
/////////////////////////////////
// perform an insert //
// todo - audit if it's a dupe //
/////////////////////////////////
await qController
.create(tableName, valuesToPost)
.create(tableName, values)
.then((record) =>
{
if (props.isModal)
@ -815,16 +515,10 @@ function EntityForm(props: Props): JSX.Element
}
else
{
let warningMessage = null;
if(record.warnings && record.warnings.length && record.warnings.length > 0)
{
warningMessage = record.warnings[0];
}
const path = props.isCopy ?
location.pathname.replace(new RegExp(`/${props.id}/copy$`), "/" + record.values.get(tableMetaData.primaryKeyField))
: location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField));
navigate(path, {state: {createSuccess: true, warning: warningMessage}});
navigate(path, {state: {createSuccess: true}});
}
})
.catch((error) =>
@ -849,19 +543,6 @@ function EntityForm(props: Props): JSX.Element
const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`;
let body;
const getSectionHelp = (section: QTableSection) =>
{
const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
const formattedHelpContent = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableMetaData.name};section:${section.name}`} />;
return formattedHelpContent && (
<Box px={"1.5rem"} fontSize={"0.875rem"}>
{formattedHelpContent}
</Box>
)
}
if (notAllowedError)
{
body = (
@ -883,10 +564,9 @@ function EntityForm(props: Props): JSX.Element
}
else
{
const cardElevation = props.isModal ? 3 : 1;
body = (
<Box mb={3}>
{
(alertContent || warningContent) &&
<Grid container spacing={3}>
<Grid item xs={12}>
{alertContent ? (
@ -901,7 +581,6 @@ function EntityForm(props: Props): JSX.Element
) : ("")}
</Grid>
</Grid>
}
<Grid container spacing={3}>
{
!props.isModal &&
@ -939,12 +618,11 @@ function EntityForm(props: Props): JSX.Element
<MDTypography variant="h5">{formTitle}</MDTypography>
</Box>
</Box>
{t1section && getSectionHelp(t1section)}
{
t1sectionName && formFields ? (
<Box px={3}>
<Box pb={"0.25rem"} width="100%">
{getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)}
<Box pb={1} px={3}>
<Box p={3} width="100%">
{getFormSection(values, touched, formFields.get(t1sectionName), errors)}
</Box>
</Box>
) : null
@ -953,7 +631,16 @@ function EntityForm(props: Props): JSX.Element
</Box>
{formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => (
<Box key={`edit-card-${section.name}`} pb={3}>
{renderSection(section, values, touched, formFields, errors)}
<Card id={section.name} sx={{overflow: "visible", scrollMarginTop: "100px"}} elevation={cardElevation}>
<MDTypography variant="h6" p={3} pb={1}>
{section.label}
</MDTypography>
<Box pb={1} px={3}>
<Box p={3} width="100%">
{getFormSection(values, touched, formFields.get(section.name), errors)}
</Box>
</Box>
</Card>
</Box>
)) : null}
@ -968,23 +655,6 @@ function EntityForm(props: Props): JSX.Element
)}
</Formik>
{
showEditChildForm &&
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}
closeModalHandler={closeEditChildForm}
table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/>
</div>
</Modal>
}
</Grid>
</Grid>
</Box>

View File

@ -25,7 +25,6 @@ import Icon from "@mui/material/Icon";
import {ReactNode, useContext} from "react";
import {Link} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
interface Props
@ -70,7 +69,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
}
const routes: string[] | any = route.slice(0, -1);
const {pathToLabelMap, branding} = useContext(QContext);
const {pageHeader, pathToLabelMap, branding} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
@ -92,18 +91,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
let accumulatedPath = "";
for (let i = 0; i < routes.length; i++)
{
////////////////////////////////////////////////////////
// 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")
if(routes[i] === "savedFilter")
{
continue;
}
@ -124,31 +112,48 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
<Box mr={{xs: 0, xl: 8}}>
<MuiBreadcrumbs
sx={{
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main,
"& li": {
lineHeight: "unset!important"
},
"& a": {
color: colors.gray.main
},
"& .MuiBreadcrumbs-separator": {
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main
color: ({palette: {white, grey}}) => (light ? white.main : grey[600]),
},
}}
>
<Link to="/">
<Icon sx={{fontSize: "1.25rem!important", position: "relative", top: "0.25rem"}}>{icon}</Icon>
<MDTypography
component="span"
variant="body2"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
<Icon>{icon}</Icon>
</MDTypography>
</Link>
{fullRoutes.map((fullRoute: string) => (
<Link to={fullRoute} key={fullRoute}>
<MDTypography
component="span"
variant="button"
fontWeight="regular"
textTransform="capitalize"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
{fullPathToLabel(fullRoute, fullRoute.replace(/.*\//, ""))}
</MDTypography>
</Link>
))}
</MuiBreadcrumbs>
<MDTypography
pt={1}
fontWeight="bold"
textTransform="capitalize"
variant="h5"
color={light ? "white" : "dark"}
noWrap
>
{pageHeader}
</MDTypography>
</Box>
);
}

View File

@ -19,22 +19,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Popper, InputAdornment} from "@mui/material";
import {Popper} 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";
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 {navbar, navbarContainer, navbarIconButton, navbarRow,} from "qqq/components/horseshoe/Styles";
import {setTransparentNavbar, useMaterialUIController,} from "qqq/context";
import HistoryUtils from "qqq/utils/HistoryUtils";
// Declaring prop types for NavBar
@ -56,7 +57,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{
const [navbarType, setNavbarType] = useState<"fixed" | "absolute" | "relative" | "static" | "sticky">();
const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, transparentNavbar, fixedNavbar, darkMode,} = controller;
const {transparentNavbar, fixedNavbar, darkMode,} = controller;
const [openMenu, setOpenMenu] = useState<any>(false);
const [history, setHistory] = useState<any>([] as HistoryEntry[]);
const [autocompleteValue, setAutocompleteValue] = useState<any>(null);
@ -64,8 +65,6 @@ 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
@ -106,8 +105,6 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
return () => window.removeEventListener("scroll", handleTransparentNavbar);
}, [dispatch, fixedNavbar]);
const handleMiniSidenav = () => setMiniSidenav(dispatch, !miniSidenav);
const goToHistory = (path: string) =>
{
navigate(path);
@ -160,20 +157,12 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
options={history}
autoHighlight
blurOnSelect
style={{width: "16rem"}}
style={{width: "200px"}}
onOpen={handleHistoryOnOpen}
onChange={handleAutocompleteOnChange}
PopperComponent={CustomPopper}
isOptionEqualToValue={(option, value) => option.id === value.id}
sx={recentlyViewedMenu}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end">
<Icon sx={{position: "relative", right: "-1rem"}}>keyboard_arrow_down</Icon>
</InputAdornment>
)
}} />}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" />}
renderOption={(props, option: HistoryEntry) => (
<Box {...props} component="li" key={option.id} sx={{width: "auto"}}>
<Box sx={{width: "auto", px: "8px", whiteSpace: "overflow"}} key={option.id}>
@ -186,6 +175,22 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
);
}
// Render the notifications menu
const renderMenu = () => (
<Menu
anchorEl={openMenu}
anchorReference={null}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
open={Boolean(openMenu)}
onClose={handleCloseMenu}
sx={{mt: 2}}
/>
);
// Styles for the navbar icons
const iconsStyle = ({
palette: {dark, white, text},
@ -235,27 +240,29 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
>
<Toolbar sx={navbarContainer}>
<Box color="inherit" mb={{xs: 1, md: 0}} sx={(theme) => navbarRow(theme, {isMini})}>
<IconButton size="small" disableRipple color="inherit" sx={navbarMobileMenu} onClick={handleMiniSidenav}>
<Icon sx={iconsStyle} fontSize="large">menu</Icon>
</IconButton>
<QBreadcrumbs icon="home" title={breadcrumbTitle} route={route} light={light} />
</Box>
{isMini ? null : (
<Box sx={(theme) => navbarRow(theme, {isMini})}>
<Box pr={0} mr={-2}>
<Box pr={1}>
{renderHistory()}
</Box>
<Box color={light ? "white" : "inherit"}>
<IconButton
size="small"
color="inherit"
sx={navbarIconButton}
onClick={handleOpenMenu}
>
<Badge badgeContent={0} color="error" variant="dot">
<Icon sx={iconsStyle}>notifications</Icon>
</Badge>
</IconButton>
{renderMenu()}
</Box>
</Box>
)}
</Toolbar>
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>
}
</AppBar>
);
}

View File

@ -20,7 +20,6 @@
*/
import {Theme} from "@mui/material/styles";
import colors from "qqq/assets/theme/base/colors";
function navbar(theme: Theme | any, ownerState: any)
{
@ -66,12 +65,12 @@ function navbar(theme: Theme | any, ownerState: any)
return color;
},
top: absolute ? 0 : pxToRem(12),
minHeight: "auto",
minHeight: pxToRem(75),
display: "grid",
alignItems: "center",
borderRadius: borderRadius.xl,
paddingTop: pxToRem(0),
paddingBottom: pxToRem(0),
paddingTop: pxToRem(8),
paddingBottom: pxToRem(8),
paddingRight: absolute ? pxToRem(8) : 0,
paddingLeft: absolute ? pxToRem(16) : 0,
@ -85,7 +84,7 @@ function navbar(theme: Theme | any, ownerState: any)
"& .MuiToolbar-root": {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
alignItems: "center",
[breakpoints.up("sm")]: {
minHeight: "auto",
@ -99,10 +98,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",
},
@ -111,10 +110,11 @@ const navbarContainer = ({breakpoints}: Theme): any => ({
const navbarRow = ({breakpoints}: Theme, {isMini}: any) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
[breakpoints.up("md")]: {
justifyContent: "stretch",
justifyContent: isMini ? "space-between" : "stretch",
width: isMini ? "100%" : "max-content",
},
@ -146,38 +146,12 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({
display: "none !important",
cursor: "pointer",
[breakpoints.down("sm")]: {
[breakpoints.up("xl")]: {
display: "inline-block !important",
},
});
const recentlyViewedMenu = ({breakpoints}: Theme) => ({
marginTop: "-0.5rem",
"& .MuiInputLabel-root": {
color: colors.gray.main,
fontWeight: "500",
fontSize: "1rem"
},
"& .MuiInputAdornment-root": {
marginTop: "0.5rem",
color: colors.gray.main,
fontSize: "1rem"
},
"& .MuiOutlinedInput-root": {
borderRadius: "0",
padding: "0"
},
"& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
border: "0"
},
display: "block",
[breakpoints.down("md")]: {
display: "none !important",
},
});
const navbarMobileMenu = ({breakpoints}: Theme) => ({
left: "-0.75rem",
display: "inline-block",
lineHeight: 0,
@ -193,5 +167,4 @@ export {
navbarIconButton,
navbarDesktopMenu,
navbarMobileMenu,
recentlyViewedMenu
};

View File

@ -27,7 +27,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
const {palette, boxShadows, transitions, breakpoints, functions} = theme;
const {transparentSidenav, whiteSidenav, miniSidenav, darkMode} = ownerState;
const sidebarWidth = 245;
const sidebarWidth = 250;
const {transparent, gradients, white, background} = palette;
const {xxl} = boxShadows;
const {pxToRem, linearGradient} = functions;
@ -94,9 +94,6 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
"& .MuiDrawer-paper": {
boxShadow: xxl,
border: "none",
margin: "0",
borderRadius: "0",
height: "100%",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
},

View File

@ -64,8 +64,7 @@ function collapseItem(theme: Theme, ownerState: any)
borderRadius: borderRadius.md,
cursor: "pointer",
userSelect: "none",
whiteSpace: "wrap",
overflow: "hidden",
whiteSpace: "nowrap",
boxShadow: active && !whiteSidenav && !darkMode && !transparentSidenav ? md : "none",
[breakpoints.up("xl")]: {
transition: transitions.create(["box-shadow", "background-color"], {
@ -74,10 +73,6 @@ function collapseItem(theme: Theme, ownerState: any)
}),
},
"& .MuiListItemText-primary": {
lineHeight: "revert"
},
"&:hover, &:focus": {
backgroundColor:
transparentSidenav && !darkMode

View File

@ -69,15 +69,7 @@ export default styled(TextField)(({theme, ownerState}: { theme?: Theme; ownerSta
});
return {
"& .MuiInputBase-root": {
backgroundColor: disabled ? `${grey[200]} !important` : transparent.main,
borderRadius: "0.75rem",
},
"& input": {
backgroundColor: `${transparent.main}!important`,
padding: "0.5rem",
fontSize: "1rem",
},
pointerEvents: disabled ? "none" : "auto",
...(error && errorStyles()),
...(success && successStyles()),

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -153,7 +153,7 @@ interface Types
}
const baseProperties = {
fontFamily: "\"SF Pro Display\", \"Roboto\", \"Helvetica\", \"Arial\", sans-serif",
fontFamily: "\"Roboto\", \"Helvetica\", \"Arial\", sans-serif",
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -1,158 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import React, {ReactNode} from "react";
interface FieldAutoCompleteProps
{
id: string;
metaData: QInstance;
tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string};
autoFocus?: boolean;
forceOpen?: boolean;
hiddenFieldNames?: string[];
}
FieldAutoComplete.defaultProps =
{
defaultValue: null,
autoFocus: false,
forceOpen: null,
hiddenFieldNames: []
};
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[])
{
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++)
{
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1)
{
continue;
}
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
}
}
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
{
const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames);
let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const exposedJoin = tableMetaData.exposedJoins[i];
if (metaData.tables.has(exposedJoin.joinTable.name))
{
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames);
}
}
}
function getFieldOptionLabel(option: any)
{
/////////////////////////////////////////////////////////////////////////////////////////
// note - we're using renderFieldOption below for the actual select-box options, which //
// are always jut field label (as they are under groupings that show their table name) //
/////////////////////////////////////////////////////////////////////////////////////////
if (option && option.field && option.table)
{
if (option.table.name == tableMetaData.name)
{
return (option.field.label);
}
else
{
return (option.table.label + ": " + option.field.label);
}
}
return ("");
}
//////////////////////////////////////////////////////////////////////////////////////////////
// for options, we only want the field label (contrast with what we show in the input box, //
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
//////////////////////////////////////////////////////////////////////////////////////////////
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = "";
if (option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
function isFieldOptionEqual(option: any, value: any)
{
return option.fieldName === value.fieldName;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
// doesn't open at all... so, only add the attribute at all, if forceOpen is true //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const alsoOpen: {[key: string]: any} = {}
if(forceOpen)
{
alsoOpen["open"] = forceOpen;
}
return (
<Autocomplete
id={id}
renderInput={(params) => (<TextField {...params} autoFocus={autoFocus} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @ts-ignore
defaultValue={defaultValue}
options={fieldOptions}
onChange={handleFieldChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
{...alsoOpen}
/>
);
}

View File

@ -34,6 +34,7 @@ import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
@ -71,7 +72,7 @@ function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
function GotoRecordDialog(props: Props): JSX.Element
{
const fields: QFieldMetaData[] = [];
const fields: QFieldMetaData[] = []
let pkey = props?.tableMetaData?.fields.get(props?.tableMetaData?.primaryKeyField);
let addedPkey = false;
@ -108,7 +109,7 @@ function GotoRecordDialog(props: Props): JSX.Element
const rs = {} as {[field: string]: string};
fields.forEach((field) => rs[field.name] = "");
return (rs);
};
}
const [error, setError] = useState("");
const [values, setValues] = useState(makeInitialValues());
@ -118,14 +119,14 @@ function GotoRecordDialog(props: Props): JSX.Element
{
values[fieldName] = newValue;
setValues(JSON.parse(JSON.stringify(values)));
};
}
const close = () =>
{
setError("");
setValues(makeInitialValues());
props.closeHandler();
};
}
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
@ -144,7 +145,7 @@ function GotoRecordDialog(props: Props): JSX.Element
const index = targetId?.replaceAll("gotoInput-", "");
document.getElementById("gotoButton-" + index).click();
}
};
}
const closeRequested = () =>
{
@ -152,15 +153,15 @@ function GotoRecordDialog(props: Props): JSX.Element
{
close();
}
};
}
const goClicked = async (fieldName: string) =>
{
setError("");
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, null, "AND", null, 10);
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, "AND", null, 10);
try
{
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant);
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant)
if (queryResult.length == 0)
{
setError("Record not found.");
@ -183,20 +184,29 @@ function GotoRecordDialog(props: Props): JSX.Element
setError(`Error: ${(e && e.message) ? e.message : e}`);
setTimeout(() => setError(""), 6000);
}
};
}
if(props.tableMetaData)
{
if (fields.length == 0 && !error)
{
setError("This table is not configured for this feature.");
setError("This table is not configured for this feature.")
}
}
return (
<Dialog open={props.isOpen} onClose={() => closeRequested} onKeyPress={(e) => keyPressed(e)} fullWidth maxWidth={"sm"}>
<DialogTitle>Go To...</DialogTitle>
<DialogTitle sx={{display: "flex"}}>
<Box sx={{display: "flex", flexGrow: 1}}>
Go To...
</Box>
<Box sx={{display: "flex"}}>
<IconButton onClick={() =>
{
document.location.href = "/";
}}><Icon sx={{align: "right"}} fontSize="small">close</Icon></IconButton>
</Box>
</DialogTitle>
<DialogContent>
{props.subHeader}
{
@ -244,7 +254,7 @@ function GotoRecordDialog(props: Props): JSX.Element
: <Box>&nbsp;</Box>
}
</Dialog>
);
)
}
interface GotoRecordButtonProps
@ -266,7 +276,7 @@ GotoRecordButton.defaultProps = {
export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
{
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen);
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen)
function openGoto()
{

View File

@ -1,158 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
import Box from "@mui/material/Box";
import parse from "html-react-parser";
import React, {useContext} from "react";
import Markdown from "react-markdown";
import QContext from "QContext";
interface Props
{
helpContents: null | QHelpContent | QHelpContent[];
roles: string[];
heading?: string;
helpContentKey?: string;
}
HelpContent.defaultProps = {};
/*******************************************************************************
** format some content - meaning, change it from string to JSX element(s) or string.
** does a parse() for HTML, and a <Markdown> for markdown, else just text.
*******************************************************************************/
const formatHelpContent = (content: string, format: string): string | JSX.Element | JSX.Element[] =>
{
if (format == "HTML")
{
return parse(content);
}
else if (format == "MARKDOWN")
{
return (<Markdown>{content}</Markdown>)
}
return content;
}
/*******************************************************************************
** return the first help content from the list that matches the first role
** in the roles list.
*******************************************************************************/
const getMatchingHelpContent = (helpContents: QHelpContent[], roles: string[]): QHelpContent =>
{
if (helpContents)
{
if (helpContents.length == 1 && helpContents[0].roles.size == 0)
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// if there's only 1 entry, and it has no roles, then assume user wanted it globally and use it //
//////////////////////////////////////////////////////////////////////////////////////////////////
return (helpContents[0]);
}
else
{
for (let i = 0; i < roles.length; i++)
{
for (let j = 0; j < helpContents.length; j++)
{
if (helpContents[j].roles.has(roles[i]))
{
return(helpContents[j])
}
}
}
}
}
return (null);
}
/*******************************************************************************
** test if a list of help contents would find any matches from a list of roles.
*******************************************************************************/
export const hasHelpContent = (helpContents: null | QHelpContent | QHelpContent[], roles: string[]) =>
{
return getMatchingHelpContent(nullOrSingletonOrArrayToArray(helpContents), roles) != null;
}
/*******************************************************************************
**
*******************************************************************************/
const nullOrSingletonOrArrayToArray = (helpContents: null | QHelpContent | QHelpContent[]): QHelpContent[] =>
{
let array: QHelpContent[] = [];
if(Array.isArray(helpContents))
{
array = helpContents;
}
else if(helpContents != null)
{
array.push(helpContents);
}
return (array);
}
/*******************************************************************************
** component that renders a box of formatted help content, from a list of
** helpContents (from meta-data), and for a list of roles (based on what screen
*******************************************************************************/
function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
const helpContentsArray = nullOrSingletonOrArrayToArray(helpContents);
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null;
if (helpHelpActive)
{
if (!selectedHelpContent)
{
selectedHelpContent = new QHelpContent({content: ""});
}
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
}
else if(selectedHelpContent)
{
content = selectedHelpContent.content;
}
///////////////////////////////////////////////////
// if content was found, format it and return it //
///////////////////////////////////////////////////
if (content)
{
return <Box display="inline" className="helpContent">
{heading && <span className="header">{heading}</span>}
{formatHelpContent(content, selectedHelpContent.format)}
</Box>;
}
return (null);
}
export default HelpContent;

View File

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

View File

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

View File

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

View File

@ -28,12 +28,11 @@ interface TabPanelProps
children?: React.ReactNode;
index: number;
value: number;
style?: any;
}
export default function TabPanel(props: TabPanelProps)
{
const {children, value, index, style, ...other} = props;
const {children, value, index, ...other} = props;
return (
<div
@ -41,7 +40,6 @@ export default function TabPanel(props: TabPanelProps)
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
style={style}
{...other}
>
{value === index && (

View File

@ -155,7 +155,7 @@ function ValidationReview({
"false",
"Skip Validation. Submit the records for immediate processing", (
<div>
If you choose this option, the input records will immediately be processed.
If you choose this option, the records input records will immediately be processed.
You will be told how many records were successfully processed, and which ones had issues after the processing is completed.
<br />
<br />

View File

@ -1,881 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 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 ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
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";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
interface BasicAndAdvancedQueryControlsProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
savedViewsComponent: JSX.Element;
columnMenuComponent: JSX.Element;
quickFilterFieldNames: string[];
setQuickFilterFieldNames: (names: string[]) => void;
queryFilter: QQueryFilter;
setQueryFilter: (queryFilter: QQueryFilter) => void;
gridApiRef: React.MutableRefObject<GridApiPro>;
/////////////////////////////////////////////////////////////////////////////////////////////
// this prop is used as a way to recognize changes in the query filter internal structure, //
// since the queryFilter object (reference) doesn't get updated //
/////////////////////////////////////////////////////////////////////////////////////////////
queryFilterJSON: string;
mode: string;
setMode: (mode: string) => void;
}
let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the
** RecordQueryOrig screen.
**
** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user
** does things on that screen, that we need to know about in here.
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props;
/////////////////////
// state variables //
/////////////////////
const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData));
const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true])));
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null);
const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [mouseOverElement, setMouseOverElement] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const {accentColor} = useContext(QContext);
/////////////////////////////////////////////////
// temporary, until we implement sub-filtering //
/////////////////////////////////////////////////
const [isQueryTooComplex, setIsQueryTooComplex] = useState(false);
//////////////////////////////////////////////////////////////////////////////////
// make some functions available to our parent - so it can tell us to do things //
//////////////////////////////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason);
},
addField(fieldName: string)
{
addQuickFilterField({fieldName: fieldName}, "columnMenu");
},
getCurrentMode()
{
return (mode);
}
};
});
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** for a given field, set its default operator for quick-filter dropdowns.
*******************************************************************************/
function getDefaultOperatorForField(field: QFieldMetaData)
{
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE)
{
defaultOperator = QCriteriaOperator.GREATER_THAN;
}
else if (field?.type == QFieldType.BOOLEAN)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
defaultOperator = null;
}
return defaultOperator;
}
/*******************************************************************************
** Callback passed into the QuickFilter component, to update the criteria
** after user makes changes to it or to clear it out.
*******************************************************************************/
const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) =>
{
let found = false;
let foundIndex = null;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if (queryFilter.criteria[i].fieldName == newCriteria.fieldName)
{
queryFilter.criteria[i] = newCriteria;
found = true;
foundIndex = i;
break;
}
}
if (doClearCriteria)
{
if (found)
{
queryFilter.criteria.splice(foundIndex, 1);
setQueryFilter(queryFilter);
}
return;
}
if (!found)
{
if (!queryFilter.criteria)
{
queryFilter.criteria = [];
}
queryFilter.criteria.push(newCriteria);
found = true;
}
if (found)
{
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() =>
{
setQueryFilter(queryFilter);
}, needDebounce ? 500 : 1);
forceUpdate();
}
};
/*******************************************************************************
** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component
** for a given field name.
*******************************************************************************/
const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" =>
{
const matches: QFilterCriteriaWithId[] = [];
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if (queryFilter.criteria[i].fieldName == fieldName)
{
matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId);
}
}
if (matches.length == 0)
{
return (null);
}
else if (matches.length == 1)
{
return (matches[0]);
}
else
{
return "tooComplex";
}
};
/*******************************************************************************
** Event handler for QuickFilter component, to remove a quick filter field from
** the screen.
*******************************************************************************/
const handleRemoveQuickFilterField = (fieldName: string): void =>
{
const index = quickFilterFieldNames.indexOf(fieldName);
if (index >= 0)
{
//////////////////////////////////////
// remove this field from the query //
//////////////////////////////////////
const criteria = new QFilterCriteria(fieldName, null, []);
updateQuickCriteria(criteria, false, true);
quickFilterFieldNames.splice(index, 1);
setQuickFilterFieldNames(quickFilterFieldNames);
}
};
/*******************************************************************************
** Event handler for button that opens the add-quick-filter menu
*******************************************************************************/
const openAddQuickFilterMenu = (event: any) =>
{
setAddQuickFilterMenu(event.currentTarget);
setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1);
};
/*******************************************************************************
** Handle closing the add-quick-filter menu
*******************************************************************************/
const closeAddQuickFilterMenu = () =>
{
setAddQuickFilterMenu(null);
};
/*******************************************************************************
** Add a quick-filter field to the screen, from either the user selecting one,
** or from a new query being activated, etc.
*******************************************************************************/
const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) =>
{
console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`);
if (reason == "blur")
{
//////////////////////////////////////////////////////////////////
// this keeps a click out of the menu from selecting the option //
//////////////////////////////////////////////////////////////////
return;
}
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if (defaultQuickFilterFieldNameMap[fieldName])
{
return;
}
if (quickFilterFieldNames.indexOf(fieldName) == -1)
{
/////////////////////////////////
// add the field if we need to //
/////////////////////////////////
quickFilterFieldNames.push(fieldName);
setQuickFilterFieldNames(quickFilterFieldNames);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView")
{
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
}
else if (reason == "columnMenu")
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
closeAddQuickFilterMenu();
}
};
/*******************************************************************************
**
*******************************************************************************/
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
{
let fullFieldName = field.name;
if (table && table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
};
/*******************************************************************************
** event handler for the Filter Builder button - e.g., opens the parent's grid's
** filter panel
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
{
if (!isQueryTooComplex)
{
gridApiRef.current.showFilterPanel();
}
};
/*******************************************************************************
** event handler for the clear-filters modal
*******************************************************************************/
const handleClearFiltersAction = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
{
if (isYesButton || event.key == "Enter")
{
setShowClearFiltersWarning(false);
setQueryFilter(new QQueryFilter([], queryFilter.orderBys));
}
};
/*******************************************************************************
**
*******************************************************************************/
const removeCriteriaByIndex = (index: number) =>
{
queryFilter.criteria.splice(index, 1);
setQueryFilter(queryFilter);
};
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = (thisQueryFilter: QQueryFilter) =>
{
if (queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{thisQueryFilter.criteria?.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{!isQueryTooComplex && (
mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} /></span>
)}
{counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
</span>
);
}
else
{
return (<span />);
}
})}
{thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) =>
{
return (
<React.Fragment key={j}>
{j > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span></span>}
<span style={{display: "flex", marginRight: "0.20rem"}}>(</span>
{queryToAdvancedString(filter)}
<span style={{display: "flex", marginRight: "0.20rem"}}>)</span>
</React.Fragment>
);
}))}
</Box>
);
};
/*******************************************************************************
** event handler for toggling between modes - basic & advanced.
*******************************************************************************/
const modeToggleClicked = (newValue: string | null) =>
{
if (newValue)
{
if (newValue == "basic")
{
////////////////////////////////////////////////////////////////////////////////
// we're always allowed to go to advanced - //
// but if we're trying to go to basic, make sure the filter isn't too complex //
////////////////////////////////////////////////////////////////////////////////
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("Query cannot work as basic - so - not allowing toggle to basic.");
return;
}
////////////////////////////////////////////////////////////////////////////////////////////////
// when going to basic, make sure all fields in the current query are active as quick-filters //
////////////////////////////////////////////////////////////////////////////////////////////////
if (queryFilter && queryFilter.criteria)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic");
}
}
//////////////////////////////////////////////////////////////////////////////////////
// note - this is a callback to the parent - as it is responsible for this state... //
//////////////////////////////////////////////////////////////////////////////////////
setMode(newValue);
}
};
/*******************************************************************************
** make sure that all fields in the current query are on-screen as quick-filters
** (that is, if the query can be basic)
*******************************************************************************/
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) =>
{
if (!tableMetaData || !queryFilter)
{
return;
}
//////////////////////////////////////////////
// set a flag if the query is 'too complex' //
//////////////////////////////////////////////
setIsQueryTooComplex(queryFilter.subFilters?.length > 0);
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++;
}
}
/////////////////////////////////////////////////////////////
// recursively add any children filters to the total count //
/////////////////////////////////////////////////////////////
for (let i = 0; i < queryFilter.subFilters?.length; i++)
{
count += countValidCriteria(queryFilter.subFilters[i]);
}
return count;
};
/*******************************************************************************
** Event handler for setting the sort from that menu
*******************************************************************************/
const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void =>
{
const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name;
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)];
setQueryFilter(queryFilter);
forceUpdate();
};
/*******************************************************************************
** event handler for a click on a field's up or down arrow in the sort menu
*******************************************************************************/
const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void =>
{
event.stopPropagation();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isAscending = event.target.innerHTML == "arrow_upward";
const isDescending = event.target.innerHTML == "arrow_downward";
if (isAscending || isDescending)
{
handleSetSort(field, table, isAscending);
}
};
/*******************************************************************************
** event handler for clicking the current sort up/down arrow, to toggle direction.
*******************************************************************************/
function toggleSortDirection(event: React.MouseEvent<HTMLSpanElement, MouseEvent>): void
{
event.stopPropagation();
try
{
queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending;
setQueryFilter(queryFilter);
forceUpdate();
}
catch (e)
{
console.log(`Error toggling sort: ${e}`);
}
}
/////////////////////////////////
// set up the sort menu button //
/////////////////////////////////
let sortButtonContents = <>Sort...</>;
if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
sortButtonContents = <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastIndex, setLastIndex] = useState(queryFilterJSON);
if (queryFilterJSON != lastIndex)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded");
setLastIndex(queryFilterJSON);
}
///////////////////////////////////////////////////
// set some status flags based on current filter //
///////////////////////////////////////////////////
const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0;
const {canFilterWorkAsBasic, canFilterWorkAsAdvanced, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
let reasonWhyBasicIsDisabled = null;
if (canFilterWorkAsAdvanced && reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
{
reasonWhyBasicIsDisabled = <>
Your current Filter cannot be managed using Basic mode because:
<ul style={{marginLeft: "1rem"}}>
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
</ul>
</>;
}
if (isQueryTooComplex)
{
reasonWhyBasicIsDisabled = <>
Your current Filter is too complex to modify because it contains Sub-filters.
</>;
}
const borderGray = colors.grayLines.main;
const sortMenuComponent = (
<FieldListMenu
idPrefix="sort"
tableMetaData={tableMetaData}
placeholder="Search Fields"
buttonProps={{disableRipple: true, sx: {textTransform: "none", color: colors.gray.main, paddingRight: 0}}}
buttonChildren={sortButtonContents}
isModeSelectOne={true}
handleSelectedField={handleSetSort}
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
handleAdornmentClick={handleSetSortArrowClick}
/>);
const filterBuilderMouseEvents =
{
onMouseOver: () => handleMouseOverElement("filterBuilderButton"),
onMouseOut: () => handleMouseOutElement()
};
return (
<Box pb={mode == "advanced" ? "0.25rem" : "0"}>
{/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */}
<Box display="flex" justifyContent="space-between" pt={"0.5rem"} pb={"0.5rem"}>
<Box display="flex">
{savedViewsComponent}
{columnMenuComponent}
</Box>
<Box>
<Tooltip title={reasonWhyBasicIsDisabled}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(event, newValue) => modeToggleClicked(newValue)}
size="small"
sx={{pl: 0.5, width: "10rem"}}
>
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
<ToggleButton value="advanced">Advanced</ToggleButton>
</ToggleButtonGroup>
</Tooltip>
</Box>
</Box>
{/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */}
<Box pb={"0.25rem"}>
{
///////////////////////////////////////////////////////////////////////////////////
// basic mode - wrapping-list of fields & add-field button, then sort-by control //
///////////////////////////////////////////////////////////////////////////////////
mode == "basic" &&
<Box display="flex" alignItems="flex-start" flexShrink={1} flexGrow={1}>
<Box width="100px" flexShrink={1} flexGrow={1}>
<>
{
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (<QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={null} />);
})
}
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${borderGray}`} height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
{
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}
{
tableMetaData && <FieldListMenu
key={JSON.stringify(quickFilterFieldNames)} // use a unique key each time we open it, because we don't want the user's last selection to stick.
idPrefix="addQuickFilter"
tableMetaData={tableMetaData}
fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]}
placeholder="Search Fields"
buttonProps={{sx: quickFilterButtonStyles, startIcon: (<Icon>add</Icon>)}}
buttonChildren={"Add Filter"}
isModeSelectOne={true}
handleSelectedField={handleFieldListMenuSelection}
/>
}
</>
</Box>
<Box>
{sortMenuComponent}
</Box>
</Box>
}
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
metaData && tableMetaData && mode == "advanced" &&
<Box borderRadius="0.75rem" border={`1px solid ${borderGray}`}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box p="0.5rem">
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<>
<Button
className="filterBuilderButton"
onClick={(e) => openFilterBuilder(e)}
{...filterBuilderMouseEvents}
startIcon={<Icon>build</Icon>}
sx={{borderRadius: "0.75rem", padding: "0.5rem", pl: "1rem", fontSize: "0.875rem", fontWeight: 500, border: `1px solid ${accentColor}`, textTransform: "none"}}
>
Filter Builder
{
countValidCriteria(queryFilter) > 0 &&
<Box {...filterBuilderMouseEvents} sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px" className="filterBuilderCountBadge">
{countValidCriteria(queryFilter)}
</Box>
}
</Button>
{
hasValidFilters && mouseOverElement == "filterBuilderButton" && <span {...filterBuilderMouseEvents} className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
}
</>
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
</Box>
<Box pr={"0.5rem"}>
{sortMenuComponent}
</Box>
</Box>
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box
className="advancedQueryString"
display="inline-block"
borderTop={`1px solid ${borderGray}`}
borderRadius="0 0 0.75rem 0.75rem"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.375rem"}
p={"0.5rem"}
pb={"0.125rem"}
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
>
{queryToAdvancedString(queryFilter)}
</Box>
}
</Box>
</Box>
}
</Box>
</Box>
);
});
export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[]
{
const defaultQuickFilterFieldNames: string[] = [];
//////////////////////////////////////////////////////////////////////////////////////////////////
// check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames //
//////////////////////////////////////////////////////////////////////////////////////////////////
const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
{
if (mdbMetaData?.defaultQuickFilterFieldNames?.length)
{
for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++)
{
defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]);
}
}
}
/////////////////////////////////////////////
// if still none, then look for T1 section //
/////////////////////////////////////////////
if (defaultQuickFilterFieldNames.length == 0)
{
if (table.sections)
{
const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1");
if (t1Sections.length)
{
for (let i = 0; i < t1Sections.length; i++)
{
if (t1Sections[i].fieldNames)
{
for (let j = 0; j < t1Sections[i].fieldNames.length; j++)
{
defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]);
}
}
}
}
}
}
return (defaultQuickFilterFieldNames);
}
export default BasicAndAdvancedQueryControls;

View File

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

View File

@ -1,131 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import MenuItem from "@mui/material/MenuItem";
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
import React from "react";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{
tableMetaData: QTableMetaData;
totalRecords: number
columnsModel: GridColDef[];
columnVisibilityModel: { [index: string]: boolean };
queryFilter: QQueryFilter;
format: string;
}
/*******************************************************************************
** Component to serve as an item in the Export menu
*******************************************************************************/
export default function ExportMenuItem(props: QExportMenuItemProps)
{
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
return (
<MenuItem
disabled={totalRecords === 0}
onClick={() =>
{
///////////////////////////////////////////////////////////////////////////////
// build the list of visible fields. note, not doing them in-order (in case //
// the user did drag & drop), because column order model isn't right yet //
// so just doing them to match columns (which were pKey, then sorted) //
///////////////////////////////////////////////////////////////////////////////
const visibleFields: string[] = [];
columnsModel.forEach((gridColumn) =>
{
const fieldName = gridColumn.field;
if (columnVisibilityModel[fieldName] !== false)
{
visibleFields.push(fieldName);
}
});
//////////////////////////////////////
// construct the url for the export //
//////////////////////////////////////
const dateString = ValueUtils.formatDateTimeForFileName(new Date());
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}`;
const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter));
//////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. //
// then have that page load the url for the export. //
// If there's an error, it'll appear in that window. else, the file will download. //
//////////////////////////////////////////////////////////////////////////////////////
const exportWindow = window.open("", "_blank");
exportWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
setTimeout(() =>
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// need to encode and decode this value, so set it in the form here, instead of literally below //
//////////////////////////////////////////////////////////////////////////////////////////////////
document.getElementById("filter").value = decodeURIComponent("${encodedFilterJSON}");
document.getElementById("exportForm").submit();
}, 1);
</script>
</head>
<body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" >
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">
</form>
</body>
</html>`);
/*
// todo - probably better - generate the report in an iframe...
// only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped
// maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?)
const iframe = document.getElementById("exportIFrame");
const form = iframe.querySelector("form");
form.action = url;
form.target = "exportIFrame";
(iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue();
form.submit();
*/
///////////////////////////////////////////
// Hide the export menu after the export //
///////////////////////////////////////////
hideMenu?.();
}}
>
Export
{` ${format.toUpperCase()}`}
</MenuItem>
);
}

View File

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

View File

@ -24,7 +24,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import Autocomplete from "@mui/material/Autocomplete";
import Autocomplete, {AutocompleteRenderOptionState} 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,7 +34,6 @@ 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";
@ -53,27 +52,6 @@ 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;
@ -199,71 +177,16 @@ interface FilterCriteriaRowProps
updateBooleanOperator: (newValue: string) => void;
}
FilterCriteriaRow.defaultProps =
{
};
FilterCriteriaRow.defaultProps = {};
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean)
{
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
function isNotSet(value: any)
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++)
{
return (value === null || value == undefined || String(value).trim() === "");
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
}
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
@ -272,6 +195,27 @@ 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 //
@ -388,24 +332,6 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
{
criteria.values = newValue.implicitValues;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if(newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{
@ -457,19 +383,111 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
return (false);
};
function isFieldOptionEqual(option: any, value: any)
{
return option.fieldName === value.fieldName;
}
function getFieldOptionLabel(option: any)
{
/////////////////////////////////////////////////////////////////////////////////////////
// note - we're using renderFieldOption below for the actual select-box options, which //
// are always jut field label (as they are under groupings that show their table name) //
/////////////////////////////////////////////////////////////////////////////////////////
if(option && option.field && option.table)
{
if(option.table.name == tableMetaData.name)
{
return (option.field.label);
}
else
{
return (option.table.label + ": " + option.field.label);
}
}
return ("");
}
//////////////////////////////////////////////////////////////////////////////////////////////
// for options, we only want the field label (contrast with what we show in the input box, //
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
//////////////////////////////////////////////////////////////////////////////////////////////
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = ""
if(option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
{
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
}
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
const tooltipEnterDelay = 750;
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.";
}
}
}
}
return (
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end" pr={0.5}>
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
<Box display="inline-block">
<Tooltip title="Remove this condition from your filter" enterDelay={tooltipEnterDelay} placement="left">
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
</Tooltip>
</Box>
@ -484,10 +502,24 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
: <span />}
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} />
<Autocomplete
id={`field-${id}`}
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @ts-ignore
defaultValue={defaultFieldValue}
options={fieldOptions}
onChange={handleFieldChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
/>
</Box>
<Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
@ -514,8 +546,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
/>
</Box>
<Box display="inline-block">
<Tooltip title={criteriaStatusTooltip} enterDelay={tooltipEnterDelay} placement="bottom">
<Box display="inline-block" pl={0.5} pr={1}>
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
{
criteriaIsValid
? <Icon color="success">check</Icon>

View File

@ -44,13 +44,9 @@ interface Props
field: QFieldMetaData;
table: QTableMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
initiallyOpenMultiValuePvs?: boolean
}
FilterCriteriaRowValues.defaultProps =
{
initiallyOpenMultiValuePvs: false
};
FilterCriteriaRowValues.defaultProps = {};
export const getTypeForTextField = (field: QFieldMetaData): string =>
{
@ -94,24 +90,6 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
/*******************************************************************************
** Event handler for key-down events - specifically added here, to stop pressing
** 'tab' in a date or date-time from closing the quick-filter...
*******************************************************************************/
const handleKeyDown = (e: any) =>
{
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
{
if(e.code == "Tab")
{
console.log("Tab on date or date-time - don't close me, just move to the next sub-field!...");
e.stopPropagation();
}
}
};
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
@ -128,22 +106,20 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
autoComplete="off"
type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}
fullWidth
autoFocus={true}
/>;
};
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if (!operatorOption)
{
return null;
return <br />;
}
function saveNewPasterValues(newValues: any[])
@ -172,7 +148,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
switch (operatorOption.valueMode)
{
case ValueMode.NONE:
return null;
return <br />;
case ValueMode.SINGLE:
return makeTextField(field, criteria, valueChangeHandler);
case ValueMode.SINGLE_DATE:
@ -239,7 +215,6 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard"
/>
</Box>;
case ValueMode.PVS_MULTI:
@ -265,10 +240,8 @@ 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"
/>
</Box>;
}

View File

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

View File

@ -1,556 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
import QContext from "QContext";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
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);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
const {accentColor} = useContext(QContext);
//////////////////////
// ole' faithful... //
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement()
{
setIsMouseOver(true);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setIsMouseOver(false);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria))
{
if(isOpen)
{
////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was a criteria originally //
////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (A), because dropdown is-open");
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const newCriteria = Object.assign({}, criteriaParam) as QFilterCriteriaWithId;
setCriteria(newCriteria);
const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0];
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
}
}
/*******************************************************************************
** Test if we need to construct a new criteria object
** This is (at least for some cases) for when the criteria gets changed
** from outside of this component - e.g., a reset on the query screen
*******************************************************************************/
const criteriaNeedsReset = (): boolean =>
{
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()))
{
if(isOpen)
{
//////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was no criteria originally, //
// so, by adding this is-open check, we eliminated those. //
//////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (B), because dropdown is-open");
return (false);
}
return (true);
}
}
return (false);
}
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the operator
** 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 = () =>
{
//////////////////////////////////////////////////////////////////////////////////
// when closing the menu, that's when we'll update the criteria from the caller //
//////////////////////////////////////////////////////////////////////////////////
updateCriteria(criteria, false, false);
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 && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{
setOperatorSelectedValue(null);
setOperatorInputValue("");
}
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
** 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;
}
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
** a noop event handler, e.g., for a too-complex
*******************************************************************************/
const noop = () =>
{
};
/*******************************************************************************
** event handler that responds to 'x' button that removes the criteria from the
** quick-filter, resetting it to a new filter.
*******************************************************************************/
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
{
if(criteriaIsValid)
{
e.stopPropagation();
const newCriteria = makeNewCriteria();
updateCriteria(newCriteria, false, true);
}
}
/*******************************************************************************
** event handler for clicking the (x) icon that turns off this quick filter field.
** hands off control to the function that was passed in (e.g., from RecordQueryOrig).
*******************************************************************************/
const handleTurningOffQuickFilterField = () =>
{
closeMenu()
if(handleRemoveQuickFilterField)
{
handleRemoveQuickFilterField(criteria?.fieldName);
}
}
////////////////////////////////////////////////////////////////////////////////////
// if no field was input (e.g., record-query is still loading), return null early //
////////////////////////////////////////////////////////////////////////////////////
if(!fieldMetaData)
{
return (null);
}
//////////////////////////////////////////////////////////////////////////////////////////
// if there should be a selected value in the operator autocomplete, and it's different //
// from the last selected one, then set the state vars that control that autocomplete //
//////////////////////////////////////////////////////////////////////////////////////////
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
}
/////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a criteria, or we need to reset it (make a new one), then do so //
/////////////////////////////////////////////////////////////////////////////////////
if (criteria == null || criteriaNeedsReset())
{
makeNewCriteria();
}
/////////////////////////
// build up the button //
/////////////////////////
const tooComplex = criteriaParam == "tooComplex";
const tooltipEnterDelay = 500;
let buttonAdditionalStyles: any = {};
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
let buttonClassName = "filterNotActive";
if (criteriaIsValid)
{
buttonAdditionalStyles.backgroundColor = accentColor + " !important";
buttonAdditionalStyles.borderColor = accentColor + " !important";
buttonAdditionalStyles.color = "white !important";
buttonClassName = "filterActive";
let valuesString = FilterUtils.getValuesString(fieldMetaData, criteria, 1, "+N");
///////////////////////////////////////////
// don't show the Equals or In operators //
///////////////////////////////////////////
let operatorString = (<>{operatorSelectedValue.label}&nbsp;</>);
if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
{
operatorString = (<></>)
}
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
}
const mouseEvents =
{
onMouseOver: () => handleMouseOverElement(),
onMouseOut: () => handleMouseOutElement()
};
let button = fieldMetaData && <Button
id={`quickFilter.${fullFieldName}`}
className={buttonClassName}
{...mouseEvents}
sx={{...quickFilterButtonStyles, ...buttonAdditionalStyles, mr: "0.5rem"}}
onClick={tooComplex ? noop : handleOpenMenu}
disabled={tooComplex}
>{buttonContent}</Button>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. //
// note this was part of original design on this widget, but later deprecated... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (tooComplex)
{
////////////////////////////////////////////////////////////////////////////
// wrap button in span, so disabled button doesn't cause disabled tooltip //
////////////////////////////////////////////////////////////////////////////
return (
<Tooltip title={`Your current filter is too complex to do a Quick Filter on ${fieldMetaData.label}. Use the Filter button to edit.`} enterDelay={tooltipEnterDelay} slotProps={{popper: {sx: {top: "-0.75rem!important"}}}}>
<span>{button}</span>
</Tooltip>
);
}
/*******************************************************************************
** event handler for 'x' button - either resets the criteria or turns off the field.
*******************************************************************************/
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
{
e.stopPropagation();
if(criteriaIsValid)
{
resetCriteria(e);
}
else
{
handleTurningOffQuickFilterField();
}
}
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = fieldMetaData?.type == QFieldType.DATE_TIME ? 275 : 250
return (
<>
{button}
{
/////////////////////////////////////////////////////////////////////////////////////
// only show the 'x' if it's to clear out a valid criteria on the field, //
// or if we were given a callback to remove the quick-filter field from the screen //
/////////////////////////////////////////////////////////////////////////////////////
(criteriaIsValid || handleRemoveQuickFilterField) && isMouseOver && <span {...mouseEvents}><XIcon shade={criteriaIsValid ? "accent" : "default"} position="forQuickFilter" onClick={xClicked} /></span>
}
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
<Autocomplete
id={"criteriaOperator"}
////////////////////////////////////////////////////////////////////////////////////////////////////
// ok, so, by default, if you type an 'o' as the first letter in the FilterCriteriaRowValues box, //
// something is causing THIS element to become selected, if the first letter in its label is 'O'. //
// ... work around is to put invisible &zwnj; entity as first character in label instead... //
////////////////////////////////////////////////////////////////////////////////////////////////////
renderInput={(params) => (<TextField {...params} label={<>&zwnj;Operator</>} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={operatorOptions}
value={operatorSelectedValue as any}
inputValue={operatorInputValue}
onChange={handleOperatorChange}
onInputChange={(e, value) => setOperatorInputValue(value)}
isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)}
getOptionLabel={(option: any) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "250px"}}}}
/>
</Box>
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
<FilterCriteriaRowValues
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableForField}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>
</Box>
</Menu>
}
</>
);
}

View File

@ -1,74 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
/*******************************************************************************
** Component that is the dialog for the user to enter the selection-subset
*******************************************************************************/
export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void })
{
const [value, setValue] = useState(props.initialValue);
const handleChange = (newValue: string) =>
{
setValue(parseInt(newValue));
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
return (
<Dialog open={props.isOpen} onClose={() => props.closeHandler()} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>Subset of the Query Result</DialogTitle>
<DialogContent>
<DialogContentText>How many records do you want to select?</DialogContentText>
<TextField
autoFocus
name="selection-subset-size"
inputProps={{width: "100%", type: "number", min: 1}}
onChange={(e) => handleChange(e.target.value)}
value={value}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</DialogContent>
<DialogActions>
<QCancelButton disabled={false} onClickHandler={() => props.closeHandler()} />
<QSaveButton label="OK" iconName="check" disabled={value == undefined || isNaN(value)} onClickHandler={() => props.closeHandler(value)} />
</DialogActions>
</Dialog>
);
}

View File

@ -1,122 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import Autocomplete from "@mui/material/Autocomplete";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import React, {useEffect, useState} from "react";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
const qController = Client.getInstance();
/*******************************************************************************
** Component that is the dialog for the user to select a variant on tables with variant backends //
*******************************************************************************/
export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void })
{
const [value, setValue] = useState(null);
const [dropDownOpen, setDropDownOpen] = useState(false);
const [variants, setVariants] = useState(null);
const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`;
if (value != null)
{
localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value));
}
else
{
localStorage.removeItem(tableVariantLocalStorageKey);
}
props.closeHandler(value);
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
useEffect(() =>
{
console.log("queryVariants");
try
{
(async () =>
{
const variants = await qController.tableVariants(props.table.name);
console.log(JSON.stringify(variants));
setVariants(variants);
})();
}
catch (e)
{
console.log(e);
}
}, []);
return variants && (
<Dialog open={props.isOpen} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>{props.table.variantTableLabel}</DialogTitle>
<DialogContent>
<DialogContentText>Select the {props.table.variantTableLabel} to be used on this table:</DialogContentText>
<Autocomplete
id="tableVariantId"
sx={{width: "400px", marginTop: "10px"}}
open={dropDownOpen}
size="small"
onOpen={() =>
{
setDropDownOpen(true);
}}
onClose={() =>
{
setDropDownOpen(false);
}}
// @ts-ignore
onChange={handleVariantChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
options={variants}
renderInput={(params) => <TextField {...params} label={props.table.variantTableLabel} />}
getOptionLabel={(option) =>
{
if (typeof option == "object")
{
return (option as QTableVariant).name;
}
return option;
}}
/>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@ -1,109 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material";
import React from "react";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
interface CompositeData
{
blocks: BlockData[];
styleOverrides?: any;
layout?: string
}
interface CompositeWidgetProps
{
widgetMetaData: QWidgetMetaData;
data: CompositeData;
}
/*******************************************************************************
** Widget which is a list of Blocks.
*******************************************************************************/
export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetProps): JSX.Element
{
if (!data || !data.blocks)
{
return (<Skeleton />);
}
////////////////////////////////////////////////////////////////////////////////////
// note - these layouts are defined in qqq in the CompositeWidgetData.Layout enum //
////////////////////////////////////////////////////////////////////////////////////
let layout = data?.layout;
let boxStyle: any = {};
if (layout == "FLEX_ROW_WRAPPED")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_SPACE_BETWEEN")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "space-between"
boxStyle.gap = "0.25rem";
}
else if (layout == "TABLE_SUB_ROW_DETAILS")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "column";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.borderRight = "1px solid #D0D0D0";
}
else if (layout == "BADGES_WRAPPER")
{
boxStyle.display = "flex";
boxStyle.gap = "0.25rem";
boxStyle.padding = "0 0.25rem";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.border = "1px solid gray";
boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF";
}
if (data?.styleOverrides)
{
boxStyle = {...boxStyle, ...data.styleOverrides};
}
return (<Box sx={boxStyle} className="compositeWidget">
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} />
</React.Fragment>
))
}
</Box>);
}

View File

@ -22,20 +22,17 @@ import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Q
import {Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import parse from "html-react-parser";
import React, {useContext, useEffect, useReducer, useState} from "react";
import {useLocation} from "react-router-dom";
import QContext from "QContext";
import MDTypography from "qqq/components/legacy/MDTypography";
import TabPanel from "qqq/components/misc/TabPanel";
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
import HorizontalBarChart from "qqq/components/widgets/charts/barchart/HorizontalBarChart";
import DefaultLineChart from "qqq/components/widgets/charts/linechart/DefaultLineChart";
import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart";
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
import DividerWidget from "qqq/components/widgets/misc/Divider";
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
@ -47,8 +44,7 @@ import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
import Widget, {HeaderIcon, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, LabelComponent} from "qqq/components/widgets/Widget";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import Widget, {WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
import TableWidget from "./tables/TableWidget";
@ -62,10 +58,9 @@ interface Props
tableName?: string;
entityPrimaryKey?: string;
omitWrappingGridContainer: boolean;
areChildren?: boolean;
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
areChildren?: boolean
childUrlParams?: string
parentWidgetMetaData?: QWidgetMetaData
}
DashboardWidgets.defaultProps = {
@ -75,12 +70,12 @@ DashboardWidgets.defaultProps = {
omitWrappingGridContainer: false,
areChildren: false,
childUrlParams: "",
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
parentWidgetMetaData: null
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData}: Props): JSX.Element
{
const location = useLocation();
const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetCounter, setWidgetCounter] = useState(0);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -89,24 +84,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const [haveLoadedParams, setHaveLoadedParams] = useState(false);
const {accentColor} = useContext(QContext);
let initialSelectedTab = 0;
let selectedTabKey: string = null;
if(parentWidgetMetaData && wrapWidgetsInTabPanels)
{
selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}`
if (localStorage.getItem(selectedTabKey))
{
initialSelectedTab = Number(localStorage.getItem(selectedTabKey));
}
}
const [selectedTab, setSelectedTab] = useState(initialSelectedTab);
const changeTab = (newValue: number) =>
{
setSelectedTab(newValue);
localStorage.setItem(selectedTabKey, String(newValue));
};
useEffect(() =>
{
setWidgetData([]);
@ -174,7 +151,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
forceUpdate();
})();
};
}
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
{
@ -250,26 +227,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{
const labelAdditionalComponentsRight: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topRightInsideCardIcon = widgetMetaData.icons.get("topRightInsideCard");
if (topRightInsideCardIcon)
{
labelAdditionalComponentsRight.push(new HeaderIcon(topRightInsideCardIcon.name, topRightInsideCardIcon.path, topRightInsideCardIcon.color, "topRightInsideCard"));
}
}
const labelAdditionalComponentsLeft: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topLeftInsideCardIcon = widgetMetaData.icons.get("topLeftInsideCard");
if (topLeftInsideCardIcon)
{
labelAdditionalComponentsLeft.push(new HeaderIcon(topLeftInsideCardIcon.name, topLeftInsideCardIcon.path, topLeftInsideCardIcon.color, "topLeftInsideCard"));
}
}
return (
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
{
@ -281,7 +238,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetIndex={i}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
reloadWidgetCallback={reloadWidget}
storeDropdownSelections={widgetMetaData.storeDropdownSelections}
/>
)
@ -313,10 +270,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StackedBarChart data={widgetData[i]?.chartData} chartSubheaderData={widgetData[i]?.chartSubheaderData} />
<StackedBarChart data={widgetData[i]?.chartData}/>
</Widget>
)
}
@ -327,8 +282,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
showReloadControl={false}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div className="widgetProcessMidDiv" style={{height: "100%"}}>
<ProcessRun process={widgetData[i]?.processMetaData} defaultProcessValues={widgetData[i]?.defaultValues} isWidget={true} forceReInit={widgetCounter} />
@ -342,8 +295,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
<Box padding="1rem" sx={{width: "100%"}}>
@ -359,10 +310,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetMetaData={widgetMetaData}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
widgetData={widgetData[i]}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box>
<Box px={3} pt={0} pb={2}>
<MDTypography component="div" variant="button" color="text" fontWeight="light">
{
widgetData && widgetData[i] && widgetData[i].html ? (
@ -392,11 +341,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
isChild={areChildren}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StatisticsCard
widgetMetaData={widgetMetaData}
data={widgetData[i]}
increaseIsGood={true}
/>
@ -435,13 +381,10 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div>
<PieChart
chartData={widgetData[i]?.chartData}
chartSubheaderData={widgetData[i]?.chartSubheaderData}
description={widgetData[i]?.description}
/>
</div>
@ -472,8 +415,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<DefaultLineChart sx={{alignItems: "center"}}
data={widgetData[i]?.chartData}
@ -502,34 +443,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
/>
)
}
{
widgetMetaData.type === "composite" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} />
</Widget>
)
}
{
widgetMetaData.type === "block" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<WidgetBlock widgetMetaData={widgetMetaData} block={widgetData[i]} />
</Widget>
)
}
{
widgetMetaData.type === "dataBagViewer" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
@ -548,75 +461,32 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
</Box>
);
};
if(wrapWidgetsInTabPanels)
{
omitWrappingGridContainer = true;
}
const body: JSX.Element =
(
<>
{
widgetMetaDataList.map((widgetMetaData, i) =>
{
let renderedWidget = widgetMetaData ? renderWidget(widgetMetaData, i) : (<></>);
if (!omitWrappingGridContainer)
{
// @ts-ignore
renderedWidget = (<Grid id={widgetMetaData.name} item xxl={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget}
</Grid>);
}
if (wrapWidgetsInTabPanels)
{
renderedWidget = (<TabPanel index={i} value={selectedTab} style={{
padding: 0,
margin: "-1rem",
width: "calc(100% + 2rem)"
}}>
{renderedWidget}
</TabPanel>);
}
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>)
})
widgetMetaDataList.map((widgetMetaData, i) => (
omitWrappingGridContainer
? widgetMetaData && renderWidget(widgetMetaData, i)
:
widgetMetaData && <Grid id={widgetMetaData.name} key={`${widgetMetaData.name}-${i}`} item lg={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderWidget(widgetMetaData, i)}
</Grid>
))
}
</>
);
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs
sx={{m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
"& .MuiTabs-scroller": {
ml: 0
}
}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
{widgetMetaDataList.map((widgetMetaData, i) => (
<Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))}
</Tabs>
: <></>
return (
widgetCount > 0 ? (
<>
{tabs}
{
omitWrappingGridContainer ? body : (
<Grid container spacing={2.5}>
omitWrappingGridContainer ? body :
(
<Grid container spacing={3} pb={4}>
{body}
</Grid>
)
}
</>
) : null
);
}

View File

@ -33,7 +33,6 @@ import Client from "qqq/utils/qqq/Client";
//////////////////////////////////////////////
export interface ParentWidgetData
{
label?: string;
dropdownLabelList: string[];
dropdownNameList: string[];
dropdownDataList: {
@ -43,9 +42,7 @@ export interface ParentWidgetData
childWidgetNameList: string[];
dropdownNeedsSelectedText?: string;
storeDropdownSelections?: boolean;
csvData?: any[][];
icon?: string;
layoutType: string;
}
@ -58,7 +55,7 @@ interface Props
widgetMetaData?: QWidgetMetaData;
widgetIndex: number;
data: ParentWidgetData;
reloadWidgetCallback?: (params: string) => void;
reloadWidgetCallback?: (widgetIndex: number, params: string) => void;
entityPrimaryKey?: string;
tableName?: string;
storeDropdownSelections?: boolean;
@ -66,7 +63,6 @@ interface Props
const qController = Client.getInstance();
function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidgetCallback, entityPrimaryKey, tableName, storeDropdownSelections}: Props, ): JSX.Element
{
const [childUrlParams, setChildUrlParams] = useState((urlParams) ? urlParams : "");
@ -90,34 +86,23 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
data?.childWidgetNameList.forEach((widgetName: string) =>
{
widgetMetaDataList.push(qInstance.widgets.get(widgetName));
});
})
setWidgets(widgetMetaDataList);
}
}, [qInstance, data, childUrlParams]);
useEffect(() =>
{
setChildUrlParams(urlParams);
}, [urlParams]);
const parentReloadWidgetCallback = (data: string) =>
{
setChildUrlParams(data);
reloadWidgetCallback(data);
};
reloadWidgetCallback(widgetIndex, data);
}
///////////////////////////////////////////////////////////////////////////////////////////
// if this parent widget is in card form, and its children are too, then we need some px //
///////////////////////////////////////////////////////////////////////////////////////////
const parentIsCard = widgetMetaData && widgetMetaData.isCard;
const childrenAreCards = widgetMetaData && widgets && widgets[0] && widgets[0].isCard;
const px = (parentIsCard && childrenAreCards) ? 3 : 0;
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if this is a parent, which is not a card, then we need to omit the padding, i think, on the Widget that ultimately gets rendered //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const omitPadding = !parentIsCard;
const px = (widgetMetaData && widgetMetaData.isCard && widgets && widgets[0] && widgets[0].isCard) ? 3 : 0;
// @ts-ignore
return (
qInstance && data ? (
<Widget
@ -125,10 +110,9 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
widgetData={data}
storeDropdownSelections={storeDropdownSelections}
reloadWidgetCallback={parentReloadWidgetCallback}
omitPadding={omitPadding}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType?.toLowerCase() == "tabs"} />
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData}/>
</Box>
</Widget>
) : null

View File

@ -25,17 +25,14 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import LinearProgress from "@mui/material/LinearProgress";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import React, {useContext, useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import React, {useEffect, useState} from "react";
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
export interface WidgetData
{
@ -46,11 +43,9 @@ export interface WidgetData
id: string,
label: string
}[][];
dropdownDefaultValueList?: string[];
dropdownNeedsSelectedText?: string;
hasPermission?: boolean;
errorLoading?: boolean;
[other: string]: any;
}
@ -58,9 +53,7 @@ export interface WidgetData
interface Props
{
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
labelBoxAdditionalSx?: any;
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
children: JSX.Element;
@ -69,7 +62,6 @@ interface Props
isChild?: boolean;
footerHTML?: string;
storeDropdownSelections?: boolean;
omitPadding: boolean;
}
Widget.defaultProps = {
@ -78,10 +70,7 @@ Widget.defaultProps = {
widgetMetaData: {},
widgetData: {},
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
labelBoxAdditionalSx: {},
omitPadding: false,
};
@ -99,64 +88,34 @@ export class LabelComponent
{
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (<div>Unsupported component type</div>);
};
return (<div>Unsupported component type</div>)
}
}
/*******************************************************************************
**
*******************************************************************************/
export class HeaderIcon extends LabelComponent
export class HeaderLink extends LabelComponent
{
iconName: string;
iconPath: string;
color: string;
coloredBG: boolean;
role: string;
label: string;
to: string
iconColor: string;
bgColor: string;
constructor(iconName: string, iconPath: string, color: string, role?: string, coloredBG: boolean = true)
constructor(label: string, to: string)
{
super();
this.iconName = iconName;
this.iconPath = iconPath;
this.color = color;
this.role = role;
this.coloredBG = coloredBG;
this.iconColor = this.coloredBG ? "#FFFFFF" : this.color;
this.bgColor = this.coloredBG ? this.color : "none";
this.label = label;
this.to = to;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const styles: any = {
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
backgroundColor: this.bgColor,
borderRadius: "0.25rem"
};
if (this.role == "topLeftInsideCard")
{
styles["order"] = -1;
styles["marginRight"] = "0.5rem";
return (
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
{this.to ? <Link to={this.to}>{this.label}</Link> : null}
</Typography>
);
}
if (this.iconPath)
{
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>);
}
else
{
return (<Icon sx={{padding: "0.25rem", ...styles}} fontSize="small">{this.iconName}</Icon>);
}
};
}
@ -169,32 +128,58 @@ export class AddNewRecordButton extends LabelComponent
label: string;
defaultValues: any;
disabledFields: any;
addNewRecordCallback?: () => void;
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues, addNewRecordCallback?: () => void)
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues)
{
super();
this.table = table;
this.label = label;
this.defaultValues = defaultValues;
this.disabledFields = disabledFields;
this.addNewRecordCallback = addNewRecordCallback;
}
openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) =>
{
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`);
};
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`)
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="-0.5rem">
<Button sx={{mt: 0.75}} onClick={() => this.addNewRecordCallback ? this.addNewRecordCallback() : this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="0.25rem">
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
</Typography>
);
};
}
}
/*******************************************************************************
**
*******************************************************************************/
export class ExportDataButton extends LabelComponent
{
callbackToExport: any;
tooltipTitle: string;
isDisabled: boolean;
constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export")
{
super();
this.callbackToExport = callbackToExport;
this.isDisabled = isDisabled;
this.tooltipTitle = tooltipTitle;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title={this.tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callbackToExport()} disabled={this.isDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
}
@ -204,115 +189,45 @@ export class AddNewRecordButton extends LabelComponent
export class Dropdown extends LabelComponent
{
label: string;
dropdownMetaData: any;
options: DropdownOption[];
dropdownDefaultValue?: string;
dropdownName: string;
onChangeCallback: any;
constructor(label: string, dropdownMetaData: any, options: DropdownOption[], dropdownDefaultValue: string, dropdownName: string, onChangeCallback: any)
constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any)
{
super();
this.label = label;
this.dropdownMetaData = dropdownMetaData;
this.options = options;
this.dropdownDefaultValue = dropdownDefaultValue;
this.dropdownName = dropdownName;
this.onChangeCallback = onChangeCallback;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const label = `Select ${this.label}`;
let defaultValue = null;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`;
if (args.widgetProps.storeDropdownSelections)
{
////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////
// see if an existing value is stored in local storage, and if so set it in dropdown //
// originally we used the full object from localStorage - but - in case the label //
// changed since it was stored, we'll instead just find the option by id (or in case that //
// option isn't available anymore, then we'll select nothing instead of a missing value //
////////////////////////////////////////////////////////////////////////////////////////////
try
{
const localStorageOption = JSON.parse(localStorage.getItem(localStorageKey));
if (localStorageOption)
{
const id = localStorageOption.id;
for (let i = 0; i < this.options.length; i++)
{
if (this.options[i].id == id)
{
defaultValue = this.options[i];
///////////////////////////////////////////////////////////////////////////////////////
defaultValue = JSON.parse(localStorage.getItem(localStorageKey));
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
}
}
}
catch (e)
{
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e);
}
}
/////////////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a value selected, but there is a default from the backend, then use it. //
/////////////////////////////////////////////////////////////////////////////////////////////
if (defaultValue == null && this.dropdownDefaultValue != null)
{
for (let i = 0; i < this.options.length; i++)
{
if (this.options[i].id == this.dropdownDefaultValue)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
if (args.widgetProps.storeDropdownSelections)
{
localStorage.setItem(localStorageKey, JSON.stringify(defaultValue));
}
this.onChangeCallback(label, defaultValue);
break;
}
}
}
/////////////////////////////////////////////////////////////////////////////
// if there's a 'label for null value' (and no default from the backend), //
// then add that as an option (and select it if nothing else was selected) //
/////////////////////////////////////////////////////////////////////////////
let options = this.options;
if (this.dropdownMetaData.labelForNullValue && !this.dropdownDefaultValue)
{
const nullOption = {id: null as string, label: this.dropdownMetaData.labelForNullValue};
options = [nullOption, ...this.options];
if (!defaultValue)
{
defaultValue = nullOption;
}
}
return (
<Box mb={2} sx={{float: "right"}}>
<WidgetDropdownMenu
<Box my={2} sx={{float: "right"}}>
<DropdownMenu
name={this.dropdownName}
defaultValue={defaultValue}
sx={{marginLeft: "1rem"}}
label={label}
startIcon={this.dropdownMetaData.startIconName}
allowBackAndForth={this.dropdownMetaData.allowBackAndForth}
backAndForthInverted={this.dropdownMetaData.backAndForthInverted}
disableClearable={this.dropdownMetaData.disableClearable}
dropdownOptions={options}
sx={{width: 200, marginLeft: "15px"}}
label={`Select ${this.label}`}
dropdownOptions={this.options}
onChangeCallback={this.onChangeCallback}
width={this.dropdownMetaData.width ?? 225}
/>
</Box>
);
};
}
}
@ -331,14 +246,12 @@ export class ReloadControl extends LabelComponent
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Refresh">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>refresh</Icon>
</Button>
</Tooltip>
</Typography>);
};
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
</Typography>
);
}
}
@ -352,32 +265,16 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
const navigate = useNavigate();
const [dropdownData, setDropdownData] = useState([]);
const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState("");
const [reloading, setReloading] = useState(false);
const [dropdownDataJSON, setDropdownDataJSON] = useState("");
const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]);
const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// support for using widget (data) label as page header, w/o it disappearing if dropdowns are changed //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastSeenLabel, setLastSeenLabel] = useState("");
const [usingLabelAsTitle, setUsingLabelAsTitle] = useState(false);
const {helpHelpActive} = useContext(QContext);
function renderComponent(component: LabelComponent, componentIndex: number)
{
if (component && component.render)
{
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
}
else
{
console.log("Request to render a null component or component without a render function...");
console.log(JSON.stringify(component));
return (<></>);
}
}
useEffect(() =>
{
@ -403,7 +300,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
// for initial render, put right-components from props into the state variable //
/////////////////////////////////////////////////////////////////////////////////
const stateLabelComponentsRight = [] as LabelComponent[];
// console.log(`${props.widgetMetaData.name} initiating right-components`);
// console.log(`${props.widgetMetaData.name} init'ing right-components`);
if (props.labelAdditionalComponentsRight)
{
props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component));
@ -436,15 +333,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
{
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
let defaultValue = null;
if (props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
{
defaultValue = props.widgetData.dropdownDefaultValueList[index];
}
if (props.widgetData?.dropdownLabelList && props.widgetData?.dropdownLabelList[index] && props.widgetMetaData?.dropdowns && props.widgetMetaData?.dropdowns[index] && props.widgetData?.dropdownNameList && props.widgetData?.dropdownNameList[index])
{
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
}
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange));
});
setLabelComponentsRight(updatedStateLabelComponentsRight);
}
@ -505,7 +394,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
}
reloadWidget(dropdownData);
reloadWidget(dropdownData)
}
}
@ -533,35 +422,18 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
}
};
}
const onExportClick = () =>
const toggleFullScreenWidget = () =>
{
if (props.widgetData?.csvData)
if (fullScreenWidgetClassName)
{
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
HtmlUtils.download(fileName, csv);
setFullScreenWidgetClassName("");
}
else
{
alert("There is no data available to export.");
setFullScreenWidgetClassName("fullScreenWidget");
}
};
///////////////////////////////////////////////////////////////////////////////////////////////////////
// add the export button to the label's left elements, if the meta-data says to show it //
// don't do this for 2 types which themselves add the button (and have custom code to do the export) //
///////////////////////////////////////////////////////////////////////////////////////////////////////
let localLabelAdditionalElementsLeft = [...props.labelAdditionalElementsLeft];
if (props.widgetMetaData?.showExportButton && props.widgetMetaData?.type !== "table" && props.widgetMetaData?.type !== "childRecordList")
{
if (!localLabelAdditionalElementsLeft)
{
localLabelAdditionalElementsLeft = [];
}
localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
}
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
@ -569,7 +441,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
const isSet = (v: any): boolean =>
{
return (v !== null && v !== undefined);
};
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components //
@ -578,78 +450,26 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission)
{
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= isSet(props.widgetData?.icon);
needLabelBox ||= isSet(props.widgetMetaData?.icon);
needLabelBox ||= isSet(props.widgetData?.label);
needLabelBox ||= isSet(props.widgetMetaData?.label);
}
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
//////////////////////////////////////////////////////////////////////////////////////////
const isParentWidget = props.widgetMetaData.type == "parentWidget"; // todo - do we need to know top-level parent, vs. a nested parent?
let labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label;
if (!labelToUse)
{
/////////////////////////////////////////////////////////////////////////////////////////////
// prevent the label from disappearing, especially when it's being used as the page header //
/////////////////////////////////////////////////////////////////////////////////////////////
if (lastSeenLabel && isParentWidget && usingLabelAsTitle)
{
labelToUse = lastSeenLabel;
}
}
let labelElement = (
<Typography sx={{cursor: "default", pl: "auto", fontWeight: 600}} variant={isParentWidget && (props.widgetData.isLabelPageTitle || usingLabelAsTitle) ? "h3" : "h6"} display="inline">
{labelToUse}
</Typography>
);
let sublabelElement = (
<Box height="20px">
<Typography sx={{position: "relative", top: "-18px"}} variant="caption">
{props.widgetData?.sublabel}
</Typography>
</Box>
);
if (labelToUse && labelToUse != lastSeenLabel)
{
setLastSeenLabel(labelToUse);
setUsingLabelAsTitle(props.widgetData.isLabelPageTitle);
}
const helpRoles = ["ALL_SCREENS"]
const slotName = "label";
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
labelElement = <Tooltip title={formattedHelpContent} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
else if (props.widgetMetaData?.tooltip)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
const isTable = props.widgetMetaData.type == "table";
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
const widgetContent =
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
{
needLabelBox &&
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"}>
<Box display="flex" flexDirection="column">
<Box display="flex" alignItems="baseline">
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} height={"3.5rem"}>
<Box pt={2} pb={1}>
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box ml={1} mr={2} mt={-4} sx={{
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -667,7 +487,10 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
) :
(
<Box ml={3} mt={-4} sx={{
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -684,23 +507,29 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
)
}
{
hasPermission && labelToUse && (labelElement)
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
//////////////////////////////////////////////////////////////////////////////////////////
hasPermission && props.widgetData?.label ? (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={2} display="inline-block">
{props.widgetData.label}
</Typography>
) : (
hasPermission && props.widgetMetaData?.label && (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={3} display="inline-block">
{props.widgetMetaData.label}
</Typography>
)
)
}
{
hasPermission && (
labelComponentsLeft.map((component, i) =>
{
return (<React.Fragment key={i}>{renderComponent(component, i)}</React.Fragment>);
return (<span key={i}>{renderComponent(component, i)}</span>);
})
)
}
{localLabelAdditionalElementsLeft}
</Box>
<Box display="flex">
{
hasPermission && props.widgetData?.sublabel && (sublabelElement)
}
</Box>
</Box>
<Box>
{
@ -715,12 +544,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
}
{
///////////////////////////////////////////////////////////////////
// turning this off... for now. maybe make a property in future //
///////////////////////////////////////////////////////////////////
/*
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0", mx:-2}} /> : <Box height="0.375rem" />)
*/
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0"}} /> : <Box height="0.375rem" />)
}
{
errorLoading ? (
@ -730,7 +554,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
) : (
hasPermission && props.widgetData?.dropdownNeedsSelectedText ? (
<Box pb={3} sx={{width: "100%", textAlign: "right"}}>
<Box pb={3} pr={3} sx={{width: "100%", textAlign: "right"}}>
<Typography variant="body2">
{props.widgetData?.dropdownNeedsSelectedText}
</Typography>
@ -746,27 +570,16 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
{
!errorLoading && props?.footerHTML && (
<Box mt={isTable ? "36px" : 1} ml={isTable ? 0 : 3} mr={isTable ? 0 : 3} mb={isTable ? "-12px" : 2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
<Box mt={1} ml={3} mr={3} mb={2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
)
}
</Box>;
const padding = props.omitPadding ? "auto" : "24px 16px";
///////////////////////////////////////////////////
// try to make tables fill their entire "parent" //
///////////////////////////////////////////////////
let noCardMarginBottom = "unset";
if (isTable)
{
noCardMarginBottom = "-8px";
}
return props.widgetMetaData?.isCard
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className="widget inCard">
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
{widgetContent}
</Card>
: <span style={{width: "100%", padding: padding, marginBottom: noCardMarginBottom}} className="widget noCard">{widgetContent}</span>;
: widgetContent;
}
export default Widget;

View File

@ -1,90 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Alert, Skeleton} from "@mui/material";
import React from "react";
import BigNumberBlock from "qqq/components/widgets/blocks/BigNumberBlock";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import DividerBlock from "qqq/components/widgets/blocks/DividerBlock";
import NumberIconBadgeBlock from "qqq/components/widgets/blocks/NumberIconBadgeBlock";
import ProgressBarBlock from "qqq/components/widgets/blocks/ProgressBarBlock";
import TableSubRowDetailRowBlock from "qqq/components/widgets/blocks/TableSubRowDetailRowBlock";
import TextBlock from "qqq/components/widgets/blocks/TextBlock";
import UpOrDownNumberBlock from "qqq/components/widgets/blocks/UpOrDownNumberBlock";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
interface WidgetBlockProps
{
widgetMetaData: QWidgetMetaData;
block: BlockData;
}
/*******************************************************************************
** Component to render a single Block in the widget framework!
*******************************************************************************/
export default function WidgetBlock({widgetMetaData, block}: WidgetBlockProps): JSX.Element
{
if(!block)
{
return (<Skeleton />);
}
if(!block.values)
{
block.values = {};
}
if(!block.styles)
{
block.styles = {};
}
if(block.blockTypeName == "COMPOSITE")
{
// @ts-ignore - special case for composite type block...
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} />);
}
switch(block.blockTypeName)
{
case "TEXT":
return (<TextBlock widgetMetaData={widgetMetaData} data={block} />);
case "NUMBER_ICON_BADGE":
return (<NumberIconBadgeBlock widgetMetaData={widgetMetaData} data={block} />);
case "UP_OR_DOWN_NUMBER":
return (<UpOrDownNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "TABLE_SUB_ROW_DETAIL_ROW":
return (<TableSubRowDetailRowBlock widgetMetaData={widgetMetaData} data={block} />);
case "PROGRESS_BAR":
return (<ProgressBarBlock widgetMetaData={widgetMetaData} data={block} />);
case "DIVIDER":
return (<DividerBlock widgetMetaData={widgetMetaData} data={block} />);
case "BIG_NUMBER":
return (<BigNumberBlock widgetMetaData={widgetMetaData} data={block} />);
default:
return (<Alert sx={{m: "0.5rem"}} color="warning">Unsupported block type: {block.blockTypeName}</Alert>)
}
}

View File

@ -1,100 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import React from "react";
import colors from "qqq/assets/theme/base/colors";
import {WidgetData} from "qqq/components/widgets/Widget";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
/*******************************************************************************
** Utility class used by Widgets
**
*******************************************************************************/
export class WidgetUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static generateExportButton = (onExportClick: () => void): JSX.Element =>
{
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Export">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>save_alt</Icon>
</Button>
</Tooltip>
</Typography>);
};
/*******************************************************************************
**
*******************************************************************************/
public static widgetCsvDataToString = (data: WidgetData): string =>
{
function isNumeric(x: any)
{
return !isNaN(Number(x));
}
let csv = "";
for (let i = 0; i < data.csvData.length; i++)
{
for (let j = 0; j < data.csvData[i].length; j++)
{
if (j > 0)
{
csv += ",";
}
let cell = data.csvData[i][j];
if (cell && isNumeric(String(cell)))
{
csv += cell;
}
else
{
csv += `"${ValueUtils.cleanForCsv(cell)}"`;
}
}
csv += "\n";
}
return (csv);
};
/*******************************************************************************
**
*******************************************************************************/
public static makeExportFileName = (data: WidgetData, widgetMetaData: QWidgetMetaData): string =>
{
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
return (fileName);
};
}

View File

@ -1,67 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a big number, optionally with some other stuff.
**
** ${heading}
** ${number} ${context}
*******************************************************************************/
export default function BigNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let flexJustifyContent = "normal";
let flexAlignItems = "baseline";
return (
<div style={{width: data.styles.width ?? "auto"}}>
<div style={{fontWeight: "700", fontSize: "0.875rem", color: "#3D3D3D", marginBottom: "-0.5rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
<div style={{display: "flex", alignItems: flexAlignItems, justifyContent: flexJustifyContent}}>
<div style={{display: "flex", alignItems: "baseline"}}>
<div style={{fontWeight: "700", fontSize: "2rem", marginRight: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.numberColor}}>{data.values.number}</span>
</BlockElementWrapper>
</div>
{
data.values.context &&
<div style={{fontWeight: "500", fontSize: "0.875rem", color: "#7b809a"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
}
</div>
</div>
</div>
);
}

View File

@ -1,106 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Tooltip} from "@mui/material";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {BlockData, BlockLink, BlockTooltip} from "qqq/components/widgets/blocks/BlockModels";
interface BlockElementWrapperProps
{
data: BlockData;
metaData: QWidgetMetaData;
slot: string
linkProps?: any;
children: ReactElement;
}
/*******************************************************************************
** For Blocks - wrap their "slot" elements with an optional tooltip and/or link
*******************************************************************************/
export default function BlockElementWrapper({data, metaData, slot, linkProps, children}: BlockElementWrapperProps): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
let link: BlockLink;
let tooltip: BlockTooltip;
if(slot)
{
link = data.linkMap && data.linkMap[slot.toUpperCase()];
if(!link)
{
link = data.link;
}
tooltip = data.tooltipMap && data.tooltipMap[slot.toUpperCase()];
if(!tooltip)
{
tooltip = data.tooltip;
}
}
else
{
link = data.link;
tooltip = data.tooltip;
}
if(!tooltip)
{
const helpRoles = ["ALL_SCREENS"]
///////////////////////////////////////////////////////////////////////////////////////////////
// the full keys in the helpContent table will look like: //
// widget:MyCoolWidget;slot=myBlockId,label (if the block has a blockId in data) //
// widget:MyCoolWidget;slot=label (no blockId; note, label is slot name here) //
// in the widget metaData, the map of helpContent will just have the "slot" portion as a key //
///////////////////////////////////////////////////////////////////////////////////////////////
const key = data.blockId ? `${data.blockId},${slot}` : slot;
const showHelp = helpHelpActive || hasHelpContent(metaData?.helpContent?.get(key), helpRoles);
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(key)} roles={helpRoles} helpContentKey={`widget:${metaData?.name};slot:${key}`} />;
tooltip = {title: formattedHelpContent, placement: "bottom"}
}
}
let rs = children;
if(link)
{
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>
}
if(tooltip)
{
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom"
// @ts-ignore - placement possible values
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>
}
return (rs);
}

View File

@ -1,59 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
export interface BlockData
{
blockId?: string;
blockTypeName: string;
tooltip?: BlockTooltip;
link?: BlockLink;
tooltipMap?: {[slot: string]: BlockTooltip};
linkMap?: {[slot: string]: BlockLink};
values: any;
styles?: any;
}
export interface BlockTooltip
{
title: string | JSX.Element;
placement: string;
}
export interface BlockLink
{
href: string;
target: string;
}
export interface StandardBlockComponentProps
{
widgetMetaData: QWidgetMetaData;
data: BlockData;
}

View File

@ -1,33 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a simple dividing line
** margins & width are set such that it covers the padding of a card.
** if we need to use it differently, a style attribute should be added to its backend data.
*******************************************************************************/
export default function DividerBlock({}: StandardBlockComponentProps): JSX.Element
{
return (<div style={{margin: "1rem -1rem", width: "calc(100% + 2rem)", borderBottom: "1px solid #E0E0E0"}} />);
}

View File

@ -1,48 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a number, and an icon, like a badge.
**
** ${number} ${icon}
*******************************************************************************/
export default function NumberIconBadgeBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "inline-block", whiteSpace: "nowrap", color: data.styles.color}}>
{
data.values.number &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.color, fontSize: "0.875rem"}}>{data.values.number}</span>
</BlockElementWrapper>
}
{
data.values.iconName &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="icon">
<Icon style={{color: data.styles.color, fontSize: "1rem", position: "relative", top: "3px"}}>{data.values.iconName}</Icon>
</BlockElementWrapper>
}
</div>);
}

View File

@ -1,70 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Typography from "@mui/material/Typography";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a progress bar!
**
** Values:
** ${heading}
** [${percent}===___] ${value ?? percent}
**
** Slots:
** ${heading}
** ${bar} ${value}
*******************************************************************************/
export default function ProgressBarBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<Typography component="div" variant="button" color="text" fontWeight="light" sx={{textTransform: "none"}}>
{
data.values.heading &&
<div style={{marginBottom: "0.25rem", fontWeight: 500, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
}
<div style={{display: "flex", alignItems: "center", marginBottom: "0.75rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="bar" linkProps={{style: {width: "100%"}}}>
<div style={{background: "#E0E0E0", width: "100%", borderRadius: "0.5rem", height: "1rem"}}>
{
data.values.percent > 0 ? <div style={{background: data.styles.barColor ?? "#0062ff", minWidth: "1rem", width: `${data.values.percent}%`, borderRadius: "0.5rem", height: "1rem"}}></div> : <></>
}
</div>
</BlockElementWrapper>
<div style={{width: "60px", textAlign: "right", fontWeight: 600, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span>{data.values.value ?? `${(data.values.percent as number).toFixed(1)}%`}</span>
</BlockElementWrapper>
</div>
</div>
</Typography>);
}

View File

@ -1,54 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a label & value, meant to be used as a detail-row in a
** sub-row within a table widget
**
** ${label} ${value}
*******************************************************************************/
export default function TableSubRowDetailRowBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "flex", maxWidth: "calc(100% - 24px)", justifyContent: "space-between"}}>
{
data.values.label &&
<div style={{overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="label">
<span style={{color: data.styles.labelColor}}>{data.values.label}</span>
</BlockElementWrapper>
</div>
}
{
data.values.value &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span style={{color: data.styles.valueColor}}>{data.values.value}</span>
</BlockElementWrapper>
}
</div>
);
}

View File

@ -1,37 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... just some text.
**
** ${text}
*******************************************************************************/
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<span>{data.values.text}</span>
</BlockElementWrapper>
);
}

View File

@ -1,81 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import React from "react";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders an up/down icon, a number, and some context
**
** ${icon} ${number} ${context}
*
** or, if style.isStacked:
*
** ${icon} ${number}
** ${context}
*******************************************************************************/
export default function UpOrDownNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
if (!data.styles)
{
data.styles = {};
}
if (!data.values)
{
data.values = {};
}
const UP_ICON = "arrow_drop_up";
const DOWN_ICON = "arrow_drop_down";
const defaultGreenColor = "#2BA83F";
const defaultRedColor = "#FB4141";
const goodOrBadColor = data.styles.colorOverride ?? (data.values.isGood ? defaultGreenColor : defaultRedColor);
const iconName = data.values.isUp ? UP_ICON : DOWN_ICON;
return (
<>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline"}}>
<div style={{display: "flex", alignItems: "baseline", fontWeight: 700, fontSize: ".875rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<>
<Icon sx={{color: goodOrBadColor, alignSelf: "flex-end", fontSize: "2.25rem !important", lineHeight: "0.875rem", height: "1rem", width: "2rem",}}>{iconName}</Icon>
<span style={{color: goodOrBadColor}}>{data.values.number}</span>
</>
</BlockElementWrapper>
</div>
<div style={{fontWeight: 500, fontSize: "0.875rem", color: "#7b809a", marginLeft: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
</div>
</>
);
}

View File

@ -28,7 +28,6 @@ import {Bar} from "react-chartjs-2";
import {useNavigate} from "react-router-dom";
import colors from "qqq/assets/theme/base/colors";
import {chartColors, DefaultChartData} from "qqq/components/widgets/charts/DefaultChartData";
import ChartSubheaderWithData, {ChartSubheaderData} from "qqq/components/widgets/components/ChartSubheaderWithData";
ChartJS.register(
CategoryScale,
@ -39,89 +38,30 @@ ChartJS.register(
Legend
);
export const makeOptions = (data: DefaultChartData) =>
{
return({
maintainAspectRatio: false,
export const options = {
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && data.urls && data.urls.length > elements[0].index && data.urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
{
return ("");
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {display: false},
grid: {offset: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
});
}
};
interface Props
{
data: DefaultChartData;
chartSubheaderData?: ChartSubheaderData;
}
const {gradients} = colors;
function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
function StackedBarChart({data}: Props): JSX.Element
{
const navigate = useNavigate();
@ -136,7 +76,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
navigate(data.urls[e[0]["index"]]);
}
console.log(e);
};
}
useEffect(() =>
{
@ -145,16 +85,9 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
data?.datasets.forEach((dataset: any, index: number) =>
{
if (!dataset.backgroundColor)
{
if (gradients[chartColors[index]])
{
dataset.backgroundColor = gradients[chartColors[index]].state;
}
else
{
dataset.backgroundColor = chartColors[index];
}
}
});
setStateData(stateData);
}
@ -162,12 +95,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
return data ? (
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
</Box>
</Box>
<Box p={3}><Bar data={data} options={options} getElementsAtEvent={handleClick} /></Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} /> ;
}

View File

@ -63,7 +63,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},
@ -86,7 +86,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},

View File

@ -67,7 +67,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},
@ -88,7 +88,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},

View File

@ -81,7 +81,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},
@ -107,7 +107,7 @@ const options = {
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},

View File

@ -69,7 +69,7 @@ function configs(labels: any, datasets: any)
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},
@ -90,7 +90,7 @@ function configs(labels: any, datasets: any)
font: {
size: 14,
weight: 300,
family: "SF Pro Display,Roboto",
family: "Roboto",
style: "normal",
lineHeight: 2,
},

View File

@ -30,7 +30,6 @@ import {useNavigate} from "react-router-dom";
import MDTypography from "qqq/components/legacy/MDTypography";
import {chartColors} from "qqq/components/widgets/charts/DefaultChartData";
import configs from "qqq/components/widgets/charts/piechart/PieChartConfigs";
import ChartSubheaderWithData, {ChartSubheaderData} from "qqq/components/widgets/components/ChartSubheaderWithData";
//////////////////////////////////////////
// structure of expected bar chart data //
@ -52,25 +51,21 @@ interface Props
{
description?: string;
chartData: PieChartData;
chartSubheaderData?: ChartSubheaderData;
[key: string]: any;
}
function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Element
function PieChart({description, chartData}: Props): JSX.Element
{
const navigate = useNavigate();
const [dataLoaded, setDataLoaded] = useState(false);
if (chartData && chartData.dataset)
{
if(!chartData.dataset.backgroundColors)
{
chartData.dataset.backgroundColors = chartColors;
}
}
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {}, chartData?.dataset?.urls);
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {});
useEffect(() =>
{
@ -87,15 +82,14 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
// @ts-ignore
navigate(chartData.dataset.urls[e[0]["index"]]);
}
};
}
return (
<Card sx={{boxShadow: "none", height: "100%", width: "100%", display: "flex", flexGrow: 1, border: 0}}>
<Box>
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
</Box>
<Box width="100%" height="300px">
<Card sx={{minHeight: "400px", boxShadow: "none", height: "100%", width: "100%", display: "flex", flexGrow: 1}}>
<Box mt={3}>
<Grid container alignItems="center">
<Grid item xs={12} justifyContent="center">
<Box width="100%" height="80%" py={2} pr={2} pl={2}>
{useMemo(
() => (
<Pie data={data} options={options} getElementsAtEvent={handleClick} />
@ -111,22 +105,25 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
justifyContent: "center"
}}>
justifyContent: "center"}}>
<Skeleton sx={{width: "150px", height: "150px"}} variant="circular"/>
</Box>
)
}
</Grid>
</Grid>
<Divider />
{
description && (
<>
<Divider />
<Box display="flex" flexDirection={{xs: "column", sm: "row"}} mt="auto">
<Grid container>
<Grid item xs={12}>
<Box pb={2} px={2} display="flex" flexDirection={{xs: "column", sm: "row"}} mt="auto">
<MDTypography variant="button" color="text" fontWeight="light">
{parse(description)}
</MDTypography>
</Box>
</>
</Grid>
</Grid>
)
}
</Box>

View File

@ -23,23 +23,17 @@ import colors from "qqq/assets/theme/base/colors";
const {gradients, dark} = colors;
function configs(labels: any, datasets: any, urls: string[] | undefined)
function configs(labels: any, datasets: any)
{
const backgroundColors = [];
if (datasets.backgroundColors)
{
datasets.backgroundColors.forEach((color: string) =>
{
if (gradients[color])
{
backgroundColors.push(gradients[color].state);
}
else
{
backgroundColors.push(color);
}
});
gradients[color]
? backgroundColors.push(gradients[color].state)
: backgroundColors.push(dark.main)
);
}
else
{
@ -64,60 +58,12 @@ function configs(labels: any, datasets: any, urls: string[] | undefined)
],
},
options: {
maintainAspectRatio: false,
maintainAspectRatio: true,
responsive: true,
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && urls && urls.length > elements[0].index && urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
aspectRatio: 2,
plugins: {
tooltip: {
callbacks: {
label: function(context: any)
{
let percentSuffix = "";
try
{
//////////////////////////////////////////////////////////////////////////
// make percent by dividing this slice's value by the sum of all values //
//////////////////////////////////////////////////////////////////////////
const thisSlice = context.dataset.data[context.dataIndex];
const sum = context.dataset.data.reduce((acc: number, val: number) => acc + val, 0);
percentSuffix = " (" + Number(100 * thisSlice / sum).toFixed(1) + "%)";
}
catch(e)
{
// leave percentSuffix empty
}
////////////////////////////////////////////////////////////////////////////////
// our labels already have the value in them - so just use the label in the //
// tooltip (lib by default puts label + value, so we were duplicating value!) //
// oh, and we add percent if we can //
////////////////////////////////////////////////////////////////////////////////
return context.label + percentSuffix;
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
padding: 12,
boxHeight: 8,
boxWidth: 8,
font: {
size: 14
}
}
},
},
scales: {

View File

@ -1,105 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import Typography from "@mui/material/Typography";
import React from "react";
import {Link} from "react-router-dom";
import colors from "qqq/assets/theme/base/colors";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
export interface ChartSubheaderData
{
mainNumber: number;
vsPreviousPercent: number;
vsPreviousNumber: number;
isUpVsPrevious: boolean;
isGoodVsPrevious: boolean;
vsDescription: string;
mainNumberUrl: string;
previousNumberUrl: string;
}
interface Props
{
chartSubheaderData: ChartSubheaderData;
}
const GOOD_COLOR = colors.success.main;
const BAD_COLOR = colors.error.main;
const UP_ICON = "arrow_drop_up";
const DOWN_ICON = "arrow_drop_down";
function StackedBarChart({chartSubheaderData}: Props): JSX.Element
{
let color = "black";
if (chartSubheaderData && chartSubheaderData.isGoodVsPrevious != null)
{
color = chartSubheaderData.isGoodVsPrevious ? GOOD_COLOR : BAD_COLOR;
}
let iconName: string = null;
if (chartSubheaderData && chartSubheaderData.isUpVsPrevious != null)
{
iconName = chartSubheaderData.isUpVsPrevious ? UP_ICON : DOWN_ICON;
}
let mainNumberElement = <Typography variant="h3" display="inline">{ValueUtils.getFormattedNumber(chartSubheaderData.mainNumber)}</Typography>;
if(chartSubheaderData.mainNumberUrl)
{
mainNumberElement = <Link to={chartSubheaderData.mainNumberUrl}>{mainNumberElement}</Link>
}
mainNumberElement = <Box pr={1}>{mainNumberElement}</Box>
let previousNumberElement = (
<>
<Typography display="block" variant="body2" sx={{color: colors.gray.main, fontSize: ".875rem", fontWeight: 500}}>
&nbsp;{chartSubheaderData.vsDescription}
{chartSubheaderData.vsPreviousNumber && (<>&nbsp;({ValueUtils.getFormattedNumber(chartSubheaderData.vsPreviousNumber)})</>)}
</Typography>
</>
)
if(chartSubheaderData.previousNumberUrl)
{
previousNumberElement = <Link to={chartSubheaderData.previousNumberUrl}>{previousNumberElement}</Link>
}
return chartSubheaderData ? (
<Box display="inline-flex" alignItems="flex-end" flexWrap="wrap">
{mainNumberElement}
{
chartSubheaderData.vsPreviousPercent != null && iconName != null && (
<Box display="inline-flex" alignItems="baseline" pb={0.5} ml={-0.5}>
<Icon fontSize="medium" sx={{color: color, alignSelf: "flex-end"}}>{iconName}</Icon>
<Typography display="inline" variant="body2" sx={{color: color, fontSize: ".875rem", fontWeight: 500}}>{chartSubheaderData.vsPreviousPercent}%</Typography>
{previousNumberElement}
</Box>
)
}
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "12px"}} />;
}
export default StackedBarChart;

View File

@ -0,0 +1,179 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Collapse, Theme} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {SxProps} from "@mui/system";
import {Field, Form, Formik} from "formik";
import React, {useState} from "react";
import MDInput from "qqq/components/legacy/MDInput";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
export interface DropdownOption
{
id: string;
label: string;
}
/////////////////////////
// inputs and defaults //
/////////////////////////
interface Props
{
name: string;
defaultValue?: any;
label?: string;
dropdownOptions?: DropdownOption[];
onChangeCallback?: (dropdownLabel: string, data: any) => void;
sx?: SxProps<Theme>;
}
interface StartAndEndDate
{
startDate?: string,
endDate?: string
}
function parseCustomTimeValuesFromDefaultValue(defaultValue: any): StartAndEndDate
{
const customTimeValues: StartAndEndDate = {};
if(defaultValue && defaultValue.id)
{
var parts = defaultValue.id.split(",");
if(parts.length >= 2)
{
customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]);
}
if(parts.length >= 3)
{
customTimeValues["endDate"] = ValueUtils.formatDateTimeValueForForm(parts[2]);
}
}
return (customTimeValues);
}
function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndDate): StartAndEndDate
{
const backendTimeValues: StartAndEndDate = {};
if(frontendDefaultValues && frontendDefaultValues.startDate)
{
backendTimeValues.startDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.startDate);
}
if(frontendDefaultValues && frontendDefaultValues.endDate)
{
backendTimeValues.endDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.endDate);
}
return (backendTimeValues);
}
function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
{
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
const [customTimeValuesBackend, setCustomTimeValuesBackend] = useState(makeBackendValuesFromFrontendValues(customTimeValuesFrontend) as StartAndEndDate);
const [debounceTimeout, setDebounceTimeout] = useState(null as any);
const handleOnChange = (event: any, newValue: any, reason: string) =>
{
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom"
setCustomTimesVisible(isTimeframeCustom);
if(isTimeframeCustom)
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}
else
{
onChangeCallback(label, newValue);
}
};
const callOnChangeCallbackIfCustomTimeframeHasDateValues = () =>
{
if(customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"])
{
onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"});
}
}
let customTimes = <></>;
if (name == "timeframe")
{
const handleSubmit = async (values: any, actions: any) =>
{
};
const dateChanged = (fieldName: "startDate" | "endDate", event: any) =>
{
customTimeValuesFrontend[fieldName] = event.target.value;
customTimeValuesBackend[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value);
clearTimeout(debounceTimeout);
const newDebounceTimeout = setTimeout(() =>
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}, 500);
setDebounceTimeout(newDebounceTimeout);
};
customTimes = <Box sx={{display: "inline-block", position: "relative", top: "-7px"}}>
<Collapse orientation="horizontal" in={customTimesVisible}>
<Formik initialValues={customTimeValuesFrontend} onSubmit={handleSubmit}>
{({}) => (
<Form id="timeframe-form" autoComplete="off">
<Field name="startDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe Start" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("startDate", event)} />
<Field name="endDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe End" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("endDate", event)} />
</Form>
)}
</Formik>
</Collapse>
</Box>;
}
return (
dropdownOptions ? (
<span style={{whiteSpace: "nowrap", display: "flex"}} className="dashboardDropdownMenu">
<Autocomplete
defaultValue={defaultValue}
size="small"
disablePortal
id={`${label}-combo-box`}
options={dropdownOptions}
sx={{...sx, cursor: "pointer", display: "inline-block"}}
onChange={handleOnChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params: any) => <TextField {...params} label={label} />}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal"}}>{option.label}</li>
)}
/>
{customTimes}
</span>
) : null
);
}
export default DropdownMenu;

View File

@ -1,335 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Collapse, Theme, InputAdornment} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import {SxProps} from "@mui/system";
import {Field, Form, Formik} from "formik";
import React, {useState} from "react";
import colors from "qqq/assets/theme/base/colors";
import MDInput from "qqq/components/legacy/MDInput";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
export interface DropdownOption
{
id: string;
label: string;
}
/////////////////////////
// inputs and defaults //
/////////////////////////
interface Props
{
name: string;
defaultValue?: any;
label?: string;
startIcon?: string;
width?: number;
disableClearable?: boolean;
allowBackAndForth?: boolean;
backAndForthInverted?: boolean;
dropdownOptions?: DropdownOption[];
onChangeCallback?: (dropdownLabel: string, data: any) => void;
sx?: SxProps<Theme>;
}
interface StartAndEndDate
{
startDate?: string,
endDate?: string
}
function parseCustomTimeValuesFromDefaultValue(defaultValue: any): StartAndEndDate
{
const customTimeValues: StartAndEndDate = {};
if (defaultValue && defaultValue.id)
{
var parts = defaultValue.id.split(",");
if (parts.length >= 2)
{
customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]);
}
if (parts.length >= 3)
{
customTimeValues["endDate"] = ValueUtils.formatDateTimeValueForForm(parts[2]);
}
}
return (customTimeValues);
}
function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndDate): StartAndEndDate
{
const backendTimeValues: StartAndEndDate = {};
if (frontendDefaultValues && frontendDefaultValues.startDate)
{
backendTimeValues.startDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.startDate);
}
if (frontendDefaultValues && frontendDefaultValues.endDate)
{
backendTimeValues.endDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.endDate);
}
return (backendTimeValues);
}
function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
{
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
const [customTimeValuesBackend, setCustomTimeValuesBackend] = useState(makeBackendValuesFromFrontendValues(customTimeValuesFrontend) as StartAndEndDate);
const [debounceTimeout, setDebounceTimeout] = useState(null as any);
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState(defaultValue);
const [inputValue, setInputValue] = useState("");
const [backDisabled, setBackDisabled] = useState(false);
const [forthDisabled, setForthDisabled] = useState(false);
const doForceOpen = (event: React.MouseEvent<HTMLDivElement>) =>
{
setIsOpen(true);
};
function getSelectedIndex(value: DropdownOption)
{
let currentIndex = null;
for (let i = 0; i < dropdownOptions.length; i++)
{
if (value && dropdownOptions[i].id == value.id)
{
currentIndex = i;
break;
}
}
return currentIndex;
}
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1) =>
{
event.stopPropagation();
let currentIndex = getSelectedIndex(value);
if (currentIndex == null)
{
console.log("No current value.... TODO");
return;
}
if (currentIndex == 0 && direction == -1)
{
console.log("Can't go -1");
return;
}
if (currentIndex == dropdownOptions.length - 1 && direction == 1)
{
console.log("Can't go +1");
return;
}
handleOnChange(event, dropdownOptions[currentIndex + direction], "navigatedBackAndForth");
};
const handleOnChange = (event: any, newValue: any, reason: string) =>
{
setValue(newValue);
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom";
setCustomTimesVisible(isTimeframeCustom);
if (isTimeframeCustom)
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}
else
{
onChangeCallback(label, newValue);
}
/* this had bugs (seemed to not take immediate effect?), so don't use for now.
let currentIndex = getSelectedIndex(value);
if(currentIndex == 0)
{
backAndForthInverted ? setForthDisabled(true) : setBackDisabled(true);
}
else
{
backAndForthInverted ? setForthDisabled(false) : setBackDisabled(false);
}
if (currentIndex == dropdownOptions.length - 1)
{
backAndForthInverted ? setBackDisabled(true) : setForthDisabled(true);
}
else
{
backAndForthInverted ? setBackDisabled(false) : setForthDisabled(false);
}
*/
};
const handleOnInputChange = (event: any, newValue: any, reason: string) =>
{
setInputValue(newValue);
};
const callOnChangeCallbackIfCustomTimeframeHasDateValues = () =>
{
if (customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"])
{
onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"});
}
};
let customTimes = <></>;
if (name == "timeframe")
{
const handleSubmit = async (values: any, actions: any) =>
{
};
const dateChanged = (fieldName: "startDate" | "endDate", event: any) =>
{
customTimeValuesFrontend[fieldName] = event.target.value;
customTimeValuesBackend[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value);
clearTimeout(debounceTimeout);
const newDebounceTimeout = setTimeout(() =>
{
callOnChangeCallbackIfCustomTimeframeHasDateValues();
}, 500);
setDebounceTimeout(newDebounceTimeout);
};
customTimes = <Box sx={{display: "inline-block", position: "relative", top: "-7px"}}>
<Collapse orientation="horizontal" in={customTimesVisible}>
<Formik initialValues={customTimeValuesFrontend} onSubmit={handleSubmit}>
{({}) => (
<Form id="timeframe-form" autoComplete="off">
<Field name="startDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe Start" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("startDate", event)} />
<Field name="endDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe End" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("endDate", event)} />
</Form>
)}
</Formik>
</Collapse>
</Box>;
}
const startAdornment = startIcon ? <Icon sx={{fontSize: "1.25rem!important", color: colors.gray.main, paddingLeft: allowBackAndForth ? "auto" : "0.25rem", width: allowBackAndForth ? "1.5rem" : "1.75rem"}}>{startIcon}</Icon> : null;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// we tried this end-adornment, for a different style of down-arrow - but by using it, we then messed something else up (i forget what), so... not used right now //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const endAdornment = <InputAdornment position="end" sx={{position: "absolute", right: allowBackAndForth ? "-0.5rem" : "0.5rem"}}><Icon sx={{fontSize: "1.75rem!important", color: colors.gray.main}}>keyboard_arrow_down</Icon></InputAdornment>;
const fontSize = "1rem";
let optionPaddingLeftRems = 0.75;
if(startIcon)
{
optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75
}
if(allowBackAndForth)
{
optionPaddingLeftRems += 2.5;
}
return (
dropdownOptions ? (
<Box sx={{whiteSpace: "nowrap", display: "flex",
"& .MuiPopperUnstyled-root": {
border: `1px solid ${colors.grayLines.main}`,
borderTop: "none",
borderRadius: "0 0 0.75rem 0.75rem",
padding: 0,
}, "& .MuiPaper-rounded": {
borderRadius: "0 0 0.75rem 0.75rem",
}
}} className="dashboardDropdownMenu">
<Autocomplete
id={`${label}-combo-box`}
defaultValue={defaultValue}
value={value}
onChange={handleOnChange}
inputValue={inputValue}
onInputChange={handleOnInputChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
size="small"
disablePortal
disableClearable={disableClearable}
options={dropdownOptions}
sx={{
...sx,
cursor: "pointer",
display: "inline-block",
"& .MuiOutlinedInput-notchedOutline": {
border: "none"
},
}}
renderInput={(params: any) =>
<>
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<TextField {...params} placeholder={label} sx={{
"& .MuiInputBase-input": {
fontSize: fontSize
}
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
/>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
</>
}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
)}
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
slotProps={{
popper: {
sx: {
width: `${width}px!important`
}
}
}}
/>
{customTimes}
</Box>
) : null
);
}
export default WidgetDropdownMenu;

View File

@ -25,13 +25,14 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Chip} from "@mui/material";
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar";
@ -41,6 +42,8 @@ import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import DataBagDataEditor, {DataBagDataEditorProps} from "qqq/components/databags/DataBagDataEditor";
import DataBagPreview from "qqq/components/databags/DataBagPreview";
import TabPanel from "qqq/components/misc/TabPanel";
@ -54,8 +57,6 @@ import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
@ -63,11 +64,12 @@ const qController = Client.getInstance();
// Declaring props types for ViewForm
interface Props
{
dataBagId: number;
dataBagId: number
}
DataBagViewer.defaultProps =
{};
{
};
export default function DataBagViewer({dataBagId}: Props): JSX.Element
{
@ -80,7 +82,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as DataBagDataEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string);
const [failText, setFailText] = useState(null as string)
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
@ -98,7 +100,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
const criteria = [new QFilterCriteria("dataBagId", QCriteriaOperator.EQUALS, [dataBagId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25);
const versions = await qController.query("dataBagVersion", filter);
console.log("Fetched versions:");
console.log(versions);
@ -284,15 +286,18 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
sx={{m: 0, mb: 1, mt: -3}}
sx={{m: 1}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
<Tab label="Raw Data" id="simple-tab-0" aria-controls="simple-tabpanel-0" />
<Tab label="Data Preview" id="simple-tab-1" aria-controls="simple-tabpanel-1" />
<Tab label="Raw Data" id="simple-tab-0" aria-controls="simple-tabpanel-0" sx={{width: "150px"}} />
<Tab label="Data Preview" id="simple-tab-1" aria-controls="simple-tabpanel-1" sx={{width: "150px"}} />
</Tabs>
</Box>
<TabPanel index={0} value={selectedTab}>
<Grid container>

View File

@ -22,66 +22,31 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import {DataGridPro, GridCallbackDetails, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro";
import Widget, {AddNewRecordButton, LabelComponent, WidgetData} from "qqq/components/widgets/Widget";
import {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {useNavigate} from "react-router-dom";
import Widget, {AddNewRecordButton, ExportDataButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget";
import DataGridUtils from "qqq/utils/DataGridUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useRef, useState} from "react";
import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData
{
title: string;
queryOutput: {records: {values: any}[]}
childTableMetaData: QTableMetaData;
tablePath: string;
viewAllLink: string;
totalRows: number;
canAddChildRecord: boolean;
defaultValuesForNewChildRecords: {[fieldName: string]: any};
disabledFieldsForNewChildRecords: {[fieldName: string]: any};
}
interface Props
{
widgetMetaData: QWidgetMetaData;
data: ChildRecordListData;
addNewRecordCallback?: () => void;
disableRowClick: boolean;
allowRecordEdit: boolean;
editRecordCallback?: (rowIndex: number) => void;
allowRecordDelete: boolean;
deleteRecordCallback?: (rowIndex: number) => void;
data: any;
}
RecordGridWidget.defaultProps =
{
disableRowClick: false,
allowRecordEdit: false,
allowRecordDelete: false
};
RecordGridWidget.defaultProps = {};
const qController = Client.getInstance();
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback}: Props): JSX.Element
function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
{
const instance = useRef({timer: null});
const [rows, setRows] = useState([]);
const [records, setRecords] = useState([] as QRecord[])
const [columns, setColumns] = useState([]);
const [allColumns, setAllColumns] = useState([])
const [csv, setCsv] = useState(null as string);
const [fileName, setFileName] = useState(null as string);
const [gridMouseDownX, setGridMouseDownX] = useState(0);
const [gridMouseDownY, setGridMouseDownY] = useState(0);
const navigate = useNavigate();
useEffect(() =>
@ -99,7 +64,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
const tableMetaData = new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, true);
const rows = DataGridUtils.makeRows(records, tableMetaData);
/////////////////////////////////////////////////////////////////////////////////
// note - tablePath may be null, if the user doesn't have access to the table. //
@ -110,7 +75,6 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// capture all-columns to use for the export (before we might splice some away from the on-screen display) //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const allColumns = [... columns];
setAllColumns(JSON.parse(JSON.stringify(columns)));
////////////////////////////////////////////////////////////////
@ -128,32 +92,14 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
}
////////////////////////////////////
// add actions cell, if available //
////////////////////////////////////
if(allowRecordEdit || allowRecordDelete)
{
columns.unshift({
field: "_actions",
type: "string",
headerName: "Actions",
sortable: false,
filterable: false,
width: allowRecordEdit && allowRecordDelete ? 80 : 50,
renderCell: ((params: GridRenderCellParams) =>
{
return <Box>
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
</Box>
})
})
}
setRows(rows);
setRecords(records)
setColumns(columns);
}
}, [data]);
const exportCallback = () =>
{
let csv = "";
for (let i = 0; i < allColumns.length; i++)
{
@ -172,23 +118,16 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
setCsv(csv);
setFileName(fileName);
HtmlUtils.download(fileName, csv);
}
}, [data]);
///////////////////
// view all link //
///////////////////
const labelAdditionalElementsLeft: JSX.Element[] = [];
const labelAdditionalComponentsLeft: LabelComponent[] = []
if(data && data.viewAllLink)
{
labelAdditionalElementsLeft.push(
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink));
}
///////////////////
@ -210,26 +149,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
}
const onExportClick = () =>
{
if(csv)
{
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
}
}
if(widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(
<Typography key={1} variant="body2" px={0} display="inline" position="relative">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle))
////////////////////
// add new button //
@ -242,87 +162,41 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
disabledFields = data.defaultValuesForNewChildRecords;
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback))
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields))
}
/////////////////////////////////////////////////////////////////
// if a grid preference window is open, ignore and reset timer //
/////////////////////////////////////////////////////////////////
const handleRowClick = (params: GridRowParams, event: MuiEvent<React.MouseEvent>, details: GridCallbackDetails) =>
{
if(disableRowClick)
{
return;
}
(async () =>
{
const qInstance = await qController.loadMetaData()
let tablePath = qInstance.getTablePathByName(data.childTableMetaData.name)
const tablePath = qInstance.getTablePathByName(data.childTableMetaData.name)
if(tablePath)
{
tablePath = `${tablePath}/${params.row[data.childTableMetaData.primaryKeyField]}`;
DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance);
navigate(`${tablePath}/${params.id}`);
}
})();
};
/*******************************************************************************
** So that we can useGridApiContext to add event handlers for mouse down and
** row double-click (to make it so you don't accidentally click into records),
** we have to define a grid component, so even though we don't want a custom
** toolbar, that's why we have this (and why it returns empty)
*******************************************************************************/
function CustomToolbar()
{
const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, event, details ) =>
{
setGridMouseDownX(event.clientX);
setGridMouseDownY(event.clientY);
clearTimeout(instance.current.timer);
};
const handleDoubleClick: GridEventListener<"rowDoubleClick"> = (event: any) =>
{
clearTimeout(instance.current.timer);
};
const apiRef = useGridApiContext();
useGridApiEventHandler(apiRef, "cellMouseDown", handleMouseDown);
useGridApiEventHandler(apiRef, "rowDoubleClick", handleDoubleClick);
return (<GridToolbarContainer />);
}
return (
<Widget
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<Box mx={-2} mb={-3}>
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
getRowId={(row) => row.__rowIndex}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
components={{
Toolbar: CustomToolbar
}}
// components={{Toolbar: CustomToolbar, Pagination: CustomPagination, LoadingOverlay: Loading}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
@ -347,7 +221,6 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
</Box>
</Widget>
);
}

View File

@ -46,6 +46,9 @@ import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import TabPanel from "qqq/components/misc/TabPanel";
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
import ScriptEditor, {ScriptEditorProps} from "qqq/components/scripts/ScriptEditor";
@ -62,9 +65,6 @@ import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-velocity";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
@ -97,8 +97,8 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
const [selectedVersionRecord, setSelectedVersionRecord] = useState(null as QRecord);
const [scriptLogs, setScriptLogs] = useState({} as any);
const [scriptTypeRecord, setScriptTypeRecord] = useState(null as QRecord);
const [scriptTypeFileSchemaList, setScriptTypeFileSchemaList] = useState(null as QRecord[]);
const [scriptTypeRecord, setScriptTypeRecord] = useState(null as QRecord)
const [scriptTypeFileSchemaList, setScriptTypeFileSchemaList] = useState(null as QRecord[])
const [availableFileNames, setAvailableFileNames] = useState([] as string[]);
const [selectedFileName, setSelectedFileName] = useState("");
const [currentVersionId , setCurrentVersionId] = useState(null as number);
@ -106,7 +106,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as ScriptEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string);
const [failText, setFailText] = useState(null as string)
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
@ -135,7 +135,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
else if(fileMode == 2) // MULTI_PRE_DEFINED
{
const filter = new QQueryFilter([new QFilterCriteria("scriptTypeId", QCriteriaOperator.EQUALS, [scriptRecord.values.get("scriptTypeId")])], [new QFilterOrderBy("id")]);
const filter = new QQueryFilter([new QFilterCriteria("scriptTypeId", QCriteriaOperator.EQUALS, [scriptRecord.values.get("scriptTypeId")])], [new QFilterOrderBy("id")])
scriptTypeFileSchemaList = await qController.query("scriptTypeFileSchema", filter);
}
else // MULTI AD_HOC
@ -147,14 +147,14 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
setScriptTypeFileSchemaList(scriptTypeFileSchemaList);
if(scriptTypeFileSchemaList)
{
const availableFileNames = scriptTypeFileSchemaList.map((fileSchemaRecord) => fileSchemaRecord.values.get("name"));
const availableFileNames = scriptTypeFileSchemaList.map((fileSchemaRecord) => fileSchemaRecord.values.get("name"))
setAvailableFileNames(availableFileNames);
setSelectedFileName(availableFileNames[0]);
setSelectedFileName(availableFileNames[0])
}
const criteria = [new QFilterCriteria("scriptId", QCriteriaOperator.EQUALS, [scriptId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25);
const versions = await qController.query("scriptRevision", filter);
console.log("Fetched versions:");
console.log(versions);
@ -253,12 +253,12 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const handleSelectFile = (event: SelectChangeEvent) =>
{
setSelectedFileName(event.target.value);
};
}
const getSelectedFileCode = (): string =>
{
return (getSelectedVersionCode()[selectedFileName] ?? "");
};
}
const getSelectedFileType = (): string =>
{
@ -272,12 +272,12 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return ("javascript"); // have some default...
};
}
const getSelectedVersionCode = (): {[name: string]: string} =>
{
let rs: { [name: string]: string } = {};
let files = selectedVersionRecord?.associatedRecords?.get("files");
let rs: {[name: string]: string} = {}
let files = selectedVersionRecord?.associatedRecords?.get("files")
for (let j = 0; j < files?.length; j++)
{
@ -286,7 +286,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (rs);
};
}
function getVersionsList(versionRecordList: QRecord[], selectedVersionRecord: QRecord)
{
@ -348,7 +348,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
{
(async () =>
{
let filter = new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])], [new QFilterOrderBy("id", false)], null, "AND", 0, 100);
let filter = new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])], [new QFilterOrderBy("id", false)], "AND", 0, 100);
scriptLogs[scriptRevisionId] = await qController.query("scriptLog", filter);
setScriptLogs(scriptLogs);
forceUpdate();
@ -368,7 +368,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (<ScriptLogsView logs={logs} />);
};
}
let editButtonTooltip = "";
let editButtonText = "Create New Version";
@ -392,8 +392,14 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
return JSON.stringify(new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])]));
}
/*
position: relative;
left: -356px;
width: calc(100% + 380px);
*/
return (
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
<Grid container className="scriptViewer">
<Grid item xs={12}>
<Box>
{
@ -424,17 +430,20 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
sx={{m: 0, mb: 1, mt: -3}}
sx={{m: 1}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
<Tab label="Code" id="simple-tab-0" aria-controls="simple-tabpanel-0" />
<Tab label="Logs" id="simple-tab-1" aria-controls="simple-tabpanel-1" />
<Tab label="Test" id="simple-tab-1" aria-controls="simple-tabpanel-2" />
<Tab label="Docs" id="simple-tab-1" aria-controls="simple-tabpanel-3" />
<Tab label="Code" id="simple-tab-0" aria-controls="simple-tabpanel-0" sx={{width: "100px"}} />
<Tab label="Logs" id="simple-tab-1" aria-controls="simple-tabpanel-1" sx={{width: "100px"}} />
<Tab label="Test" id="simple-tab-1" aria-controls="simple-tabpanel-2" sx={{width: "100px"}} />
<Tab label="Docs" id="simple-tab-1" aria-controls="simple-tabpanel-3" sx={{width: "100px"}} />
</Tabs>
</Box>
<TabPanel index={0} value={selectedTab}>
<Grid container>
@ -489,7 +498,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
editorProps={{$blockScrolling: true}}
setOptions={{useWorker: false}}
width="100%"
height="400px"
height="368px"
value={getSelectedFileCode()}
style={{borderTop: "1px solid lightgray", borderBottomRightRadius: "1rem"}}
/>

View File

@ -18,7 +18,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {tooltipClasses, TooltipProps} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
@ -30,19 +29,16 @@ import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import parse from "html-react-parser";
import React, {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useExpanded, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
import colors from "qqq/assets/theme/base/colors";
import {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
import MDInput from "qqq/components/legacy/MDInput";
import MDPagination from "qqq/components/legacy/MDPagination";
import MDTypography from "qqq/components/legacy/MDTypography";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import DataTableBodyCell from "qqq/components/widgets/tables/cells/DataTableBodyCell";
import DataTableHeadCell from "qqq/components/widgets/tables/cells/DataTableHeadCell";
import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
import ImageCell from "qqq/components/widgets/tables/cells/ImageCell";
import {TableDataInput} from "qqq/components/widgets/tables/TableCard";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
interface Props
{
@ -51,8 +47,6 @@ interface Props
canSearch?: boolean;
showTotalEntries?: boolean;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
table: TableDataInput;
pagination?: {
variant: "contained" | "gradient";
@ -60,21 +54,8 @@ interface Props
};
isSorted?: boolean;
noEndBorder?: boolean;
widgetMetaData: QWidgetMetaData;
}
DataTable.defaultProps = {
entriesPerPage: 10,
entriesPerPageOptions: ["5", "10", "15", "20", "25"],
canSearch: false,
showTotalEntries: true,
fixedStickyLastRow: false,
fixedHeight: null,
pagination: {variant: "gradient", color: "info"},
isSorted: true,
noEndBorder: false,
};
const NoMaxWidthTooltip = styled(({className, ...props}: TooltipProps) => (
<Tooltip {...props} classes={{popper: className}} />
))({
@ -90,13 +71,10 @@ function DataTable({
hidePaginationDropdown,
canSearch,
showTotalEntries,
fixedStickyLastRow,
fixedHeight,
table,
pagination,
isSorted,
noEndBorder,
widgetMetaData
}: Props): JSX.Element
{
let defaultValue: any;
@ -105,88 +83,8 @@ function DataTable({
defaultValue = (entriesPerPage) ? entriesPerPage : "10";
entries = entriesPerPageOptions ? entriesPerPageOptions : ["10", "25", "50", "100"];
let widths = [];
for(let i = 0; i<table.columns.length; i++)
{
const column = table.columns[i];
if(column.type !== "hidden")
{
widths.push(table.columns[i].width ?? "1fr");
}
}
let showExpandColumn = false;
if(table.rows)
{
for (let i = 0; i < table.rows.length; i++)
{
if (table.rows[i].subRows)
{
showExpandColumn = true;
break;
}
}
}
const columnsToMemo = [...table.columns];
if(showExpandColumn)
{
widths.push("60px");
columnsToMemo.push(
{
///////////////////////////////
// Build our expander column //
///////////////////////////////
id: "__expander",
width: 60,
////////////////////////////////////////////////
// use this block if we want to do expand-all //
////////////////////////////////////////////////
// @ts-ignore
// header: ({getToggleAllRowsExpandedProps, isAllRowsExpanded}) => (
// <span {...getToggleAllRowsExpandedProps()}>
// {isAllRowsExpanded ? "yes" : "no"}
// </span>
// ),
header: () => (<span />),
// @ts-ignore
cell: ({row}) =>
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Use the row.canExpand and row.getToggleRowExpandedProps prop getter to build the toggle for expanding a row //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
row.canExpand ? (
<span
{...row.getToggleRowExpandedProps({
//////////////////////////////////////////////////////////////////////////////////////////
// We could use the row.depth property and paddingLeft to indicate the depth of the row //
//////////////////////////////////////////////////////////////////////////////////////////
// style: {paddingLeft: `${row.depth * 2}rem`,},
})}
>
{/* float this icon to keep it "out of the flow" - in other words, to keep it from making the row taller than it otherwise would be... */}
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_right"}</Icon>
</span>
) : null,
},
);
}
if(table.columnHeaderTooltips)
{
for (let column of columnsToMemo)
{
if(table.columnHeaderTooltips[column.accessor])
{
column.tooltip = table.columnHeaderTooltips[column.accessor];
}
}
}
const columns = useMemo<any>(() => columnsToMemo, [table]);
const columns = useMemo<any>(() => table.columns, [table]);
const data = useMemo<any>(() => table.rows, [table]);
const gridTemplateColumns = widths.join(" ");
if (!columns || !data)
{
@ -197,7 +95,6 @@ function DataTable({
{columns, data, initialState: {pageIndex: 0}},
useGlobalFilter,
useSortBy,
useExpanded,
usePagination
);
@ -216,7 +113,7 @@ function DataTable({
previousPage,
setPageSize,
setGlobalFilter,
state: {pageIndex, pageSize, globalFilter, expanded},
state: {pageIndex, pageSize, globalFilter},
}: any = tableInstance;
// Set the default value for the entries per page when component mounts
@ -296,168 +193,8 @@ function DataTable({
entriesEnd = pageSize * (pageIndex + 1);
}
let visibleFooterRows = 1;
if(expanded && expanded[`${table.rows.length-1}`])
{
//////////////////////////////////////////////////
// todo - should count how many are expanded... //
//////////////////////////////////////////////////
visibleFooterRows = 2;
}
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
{
boxStyle = isFooter
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
: {flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
}
let innerBoxStyle = {};
if(fixedStickyLastRow && isFooter)
{
innerBoxStyle = {overflowY: "auto", scrollbarGutter: "stable"};
}
return <Box sx={boxStyle}><Box sx={innerBoxStyle}>
<Table {...getTableProps()}>
{
includeHead && (
<Box component="thead" sx={{position: "sticky", top: 0, background: "white", zIndex: 10}}>
{headerGroups.map((headerGroup: any, i: number) => (
<TableRow key={i} {...headerGroup.getHeaderGroupProps()} sx={{display: "grid", gridTemplateColumns: gridTemplateColumns}}>
{headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
tooltip={column.tooltip}
>
{column.render("header")}
</DataTableHeadCell>
)
))}
</TableRow>
))}
</Box>
)
}
<TableBody {...getTableBodyProps()}>
{rows.map((row: any, key: any) =>
{
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if(row.depth > 0)
{
overrideNoEndBorder = true;
if(key + 1 < rows.length && rows[key + 1].depth == 0)
{
overrideNoEndBorder = false;
}
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if(isFooter)
{
overrideNoEndBorder = true;
}
let background = "initial";
if(isFooter)
{
background = "#EEEEEE";
}
else if(row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: background}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
)
}
{
cell.column.type === "composite" && (
<DefaultCell isFooter={isFooter}>
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "block" && (
<DefaultCell isFooter={isFooter}>
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
</Table>
</Box></Box>
}
return (
<TableContainer sx={{boxShadow: "none", height: fixedHeight ? `${fixedHeight}px` : "auto"}}>
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && ! hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || ! hidePaginationDropdown) && (
@ -503,17 +240,82 @@ function DataTable({
)}
</Box>
) : null}
<Box display="flex" justifyContent="space-between" flexDirection="column" height="100%">
{
fixedStickyLastRow ? (
<>
{getTable(true, page.slice(0, page.length - visibleFooterRows), false)}
{getTable(false, page.slice(page.length - visibleFooterRows), true)}
</>
) : getTable(true, page, false)
}
<Table {...getTableProps()}>
<Box component="thead">
{headerGroups.map((headerGroup: any, i: number) => (
<TableRow key={i} {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
width={column.width ? column.width : "auto"}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
>
{column.render("header")}
</DataTableHeadCell>
)
))}
</TableRow>
))}
</Box>
<TableBody {...getTableBodyProps()}>
{page.map((row: any, key: any) =>
{
prepareRow(row);
return (
<TableRow sx={{verticalAlign: "top"}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder && rows.length - 1 === key}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell>{parse(cell.value)}</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
</Table>
<Box
display="flex"
@ -566,4 +368,15 @@ function DataTable({
);
}
// Declaring default props for DataTable
DataTable.defaultProps = {
entriesPerPage: 10,
entriesPerPageOptions: ["5", "10", "15", "20", "25"],
canSearch: false,
showTotalEntries: true,
pagination: {variant: "gradient", color: "info"},
isSorted: true,
noEndBorder: false,
};
export default DataTable;

View File

@ -20,7 +20,6 @@
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import Table from "@mui/material/Table";
@ -43,7 +42,6 @@ import Client from "qqq/utils/qqq/Client";
export interface TableDataInput
{
columns: { [key: string]: any }[];
columnHeaderTooltips?: { [columnName: string]: string | JSX.Element }
rows: { [key: string]: any }[];
}
@ -56,14 +54,11 @@ interface Props
noRowsFoundHTML?: string;
rowsPerPage?: number;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
data: TableDataInput;
widgetMetaData: QWidgetMetaData;
}
const qController = Client.getInstance();
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown, fixedStickyLastRow, fixedHeight, widgetMetaData}: Props): JSX.Element
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}: Props): JSX.Element
{
const [qInstance, setQInstance] = useState(null as QInstance);
@ -77,21 +72,19 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
}, []);
return (
<Box className="tableCard" mx={-2} mb="-28px" pt="11px" pb="0.25rem">
<Box py={1}>
{
data && data.columns && !noRowsFoundHTML ?
<DataTable
table={data}
entriesPerPage={rowsPerPage}
hidePaginationDropdown={hidePaginationDropdown}
fixedStickyLastRow={fixedStickyLastRow}
fixedHeight={fixedHeight}
showTotalEntries={false}
isSorted={false}
widgetMetaData={widgetMetaData}
noEndBorder
/>
: noRowsFoundHTML ?
<Box p={3} pt={0} pb={3} sx={{textAlign: "center"}}>
<Box p={3} pt={1} pb={1} sx={{textAlign: "center"}}>
<MDTypography
variant="subtitle2"
color="secondary"
@ -121,7 +114,7 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
<TableRow sx={{verticalAlign: "top"}} key={`row-${i}`}>
{Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
<DefaultCell><Skeleton /></DefaultCell>
</DataTableBodyCell>
)}
</TableRow>

View File

@ -23,12 +23,9 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
// @ts-ignore
import {htmlToText} from "html-to-text";
import React, {useContext, useEffect, useState} from "react";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import React, {useEffect, useState} from "react";
import TableCard from "qqq/components/widgets/tables/TableCard";
import Widget, {WidgetData} from "qqq/components/widgets/Widget";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget";
import HtmlUtils from "qqq/utils/HtmlUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -46,9 +43,6 @@ TableWidget.defaultProps = {
function TableWidget(props: Props): JSX.Element
{
const [isExportDisabled, setIsExportDisabled] = useState(false); // hmm, would like true here, but it broke...
const [csv, setCsv] = useState(null as string);
const [fileName, setFileName] = useState(null as string);
const {helpHelpActive} = useContext(QContext);
const rows = props.widgetData?.rows;
const columns = props.widgetData?.columns;
@ -62,8 +56,14 @@ function TableWidget(props: Props): JSX.Element
}
setIsExportDisabled(isExportDisabled);
}, [props.widgetMetaData, props.widgetData]);
const exportCallback = () =>
{
if (props.widgetData && rows && columns)
{
console.log(props.widgetData);
let csv = "";
for (let j = 0; j < columns.length; j++)
{
@ -85,10 +85,7 @@ function TableWidget(props: Props): JSX.Element
}
const cell = rows[i][columns[j].accessor];
let text = cell;
if(columns[j].type != "default")
{
text = htmlToText(cell,
const text = htmlToText(cell,
{
selectors: [
{selector: "a", format: "inline"},
@ -96,62 +93,21 @@ function TableWidget(props: Props): JSX.Element
{selector: ".button", format: "skip"}
]
});
}
csv += `"${ValueUtils.cleanForCsv(text)}"`;
}
csv += "\n";
}
setCsv(csv);
console.log(csv);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
setFileName(fileName)
console.log(`useEffect, setting fileName ${fileName}`);
}
}, [props.widgetMetaData, props.widgetData]);
const onExportClick = () =>
{
if(props.widgetData?.csvData)
{
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
HtmlUtils.download(fileName, csv);
}
else if(csv)
{
const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
}
}
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(props.widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
}
//////////////////////////////////////////////////////
// look for column-header tooltips from helpContent //
//////////////////////////////////////////////////////
const columnHeaderTooltips: {[columnName: string]: JSX.Element} = {}
for (let column of props.widgetData?.columns ?? [])
{
const helpRoles = ["ALL_SCREENS"]
const slotName = `columnHeader=${column.accessor}`;
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
columnHeaderTooltips[column.accessor] = formattedHelpContent;
}
alert("There is no data available to export.");
}
};
return (
<Widget
@ -160,16 +116,13 @@ function TableWidget(props: Props): JSX.Element
reloadWidgetCallback={(data) => props.reloadWidgetCallback(data)}
footerHTML={props.widgetData?.footerHTML}
isChild={props.isChild}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []}
>
<TableCard
noRowsFoundHTML={props.widgetData?.noRowsFoundHTML}
rowsPerPage={props.widgetData?.rowsPerPage}
hidePaginationDropdown={props.widgetData?.hidePaginationDropdown}
fixedStickyLastRow={props.widgetData?.fixedStickyLastRow}
fixedHeight={props.widgetData?.fixedHeight}
data={{columns: props.widgetData?.columns, rows: props.widgetData?.rows, columnHeaderTooltips: columnHeaderTooltips}}
widgetMetaData={props.widgetMetaData}
data={{columns: props.widgetData?.columns, rows: props.widgetData?.rows}}
/>
</Widget>
);

View File

@ -22,7 +22,6 @@
import Box from "@mui/material/Box";
import {Theme} from "@mui/material/styles";
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors";
// Declaring prop types for DataTableBodyCell
interface Props
@ -39,28 +38,16 @@ function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
component="td"
textAlign={align}
py={1.5}
px={1.5}
px={3}
sx={({palette: {light}, typography: {size}, borders: {borderWidth}}: Theme) => ({
borderBottom: noBorder ? "none" : `${borderWidth[1]} solid ${colors.grayLines.main}`,
fontSize: "0.875rem",
"@media (min-width: 1440px)": {
fontSize: "1rem"
},
"@media (max-width: 1440px)": {
fontSize: "0.875rem"
},
"&:nth-child(1)": {
paddingLeft: "1rem"
},
"&:last-child": {
paddingRight: "1rem"
}
fontSize: size.sm,
borderBottom: noBorder ? "none" : `${borderWidth[1]} solid ${light.main}`,
})}
>
<Box
display="initial"
width="max-content"
color={colors.dark.main}
color="text"
>
{children}
</Box>

View File

@ -22,9 +22,7 @@
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {Theme} from "@mui/material/styles";
import Tooltip from "@mui/material/Tooltip";
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors";
import {useMaterialUIController} from "qqq/context";
// Declaring props types for DataTableHeadCell
@ -34,10 +32,9 @@ interface Props
children: ReactNode;
sorted?: false | "none" | "asce" | "desc";
align?: "left" | "right" | "center";
tooltip?: string | JSX.Element;
}
function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: Props): JSX.Element
function DataTableHeadCell({width, children, sorted, align, ...rest}: Props): JSX.Element
{
const [controller] = useMaterialUIController();
const {darkMode} = controller;
@ -47,38 +44,25 @@ function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: P
component="th"
width={width}
py={1.5}
px={1.5}
px={3}
sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({
borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`,
"&:nth-child(1)": {
paddingLeft: "1rem"
},
"&:last-child": {
paddingRight: "1rem"
},
borderBottom: `${borderWidth[1]} solid ${light.main}`,
})}
>
<Box
{...rest}
sx={({typography: {size, fontWeightBold}}: Theme) => ({
position: "relative",
color: colors.grey[700],
opacity: "0.7",
textAlign: align,
"@media (min-width: 1440px)": {
fontSize: "1rem"
},
"@media (max-width: 1440px)": {
fontSize: "0.875rem"
},
fontWeight: 600,
fontSize: size.xxs,
fontWeight: fontWeightBold,
textTransform: "uppercase",
cursor: sorted && "pointer",
userSelect: sorted && "none",
})}
>
<>
{
tooltip ? <Tooltip title={tooltip}><span style={{cursor: "default"}}>{children}</span></Tooltip> : children
}
{children}
{sorted && (
<Box
position="absolute"
@ -111,7 +95,6 @@ function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: P
</Box>
</Box>
)}
</>
</Box>
</Box>
);

View File

@ -14,31 +14,12 @@ Coded by www.creative-tim.com
*/
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
interface Props
{
isFooter: boolean
children: ReactNode;
}
function DefaultCell({isFooter, children}: Props): JSX.Element
function DefaultCell({children}: { children: ReactNode }): JSX.Element
{
return (
<MDTypography variant="button" color={colors.dark.main} sx={{
fontWeight: isFooter ? 600 : 500,
"@media (min-width: 1440px)": {
fontSize: "1rem"
},
"@media (max-width: 1440px)": {
fontSize: "0.875rem"
},
"& a": {
color: colors.blueGray.main
}
}}>
<MDTypography variant="button" fontWeight="regular" color="text">
{children}
</MDTypography>
);

View File

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

View File

@ -38,11 +38,11 @@ function DashboardLayout({children}: { children: ReactNode }): JSX.Element
return (
<Box
sx={({breakpoints, transitions, functions: {pxToRem}}) => ({
p: "20px",
p: 3,
position: "relative",
[breakpoints.up("xl")]: {
marginLeft: miniSidenav ? pxToRem(120) : pxToRem(245),
marginLeft: miniSidenav ? pxToRem(120) : pxToRem(274),
transition: transitions.create(["margin-left", "margin-right"], {
easing: transitions.easing.easeInOut,
duration: transitions.duration.standard,

View File

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

View File

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

View File

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

View File

@ -34,7 +34,6 @@ 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";
@ -75,19 +74,9 @@ function AppHome({app}: Props): JSX.Element
})();
}, []);
const mdbMetaData = app?.supplementalAppMetaData?.get("materialDashboard");
let showAppLabelOnHomeScreen = true;
let includeTableCountsOnHomeScreen = true;
if(mdbMetaData)
{
showAppLabelOnHomeScreen = mdbMetaData.showAppLabelOnHomeScreen;
includeTableCountsOnHomeScreen = mdbMetaData.includeTableCountsOnHomeScreen;
}
useEffect(() =>
{
// setPageHeader(app.label);
setPageHeader(null);
setPageHeader(app.label);
if (!qInstance)
{
@ -130,10 +119,9 @@ function AppHome({app}: Props): JSX.Element
const tableCountNumbers = new Map<string, string>();
const tableCountTexts = new Map<string, string>();
newTables.forEach((table) =>
{
if(includeTableCountsOnHomeScreen)
{
tableCounts.set(table.name, {isLoading: true, value: null});
setTimeout(async () =>
{
const tableMetaData = await qController.loadTableMetaData(table.name);
@ -174,17 +162,6 @@ function AppHome({app}: Props): JSX.Element
setTableCountTexts(tableCountTexts);
setUpdatedTableCounts(new Date());
}, 1);
}
else
{
tableCounts.set(table.name, {isLoading: false, value: null});
tableCountNumbers.set(table.name, " ");
tableCountTexts.set(table.name, " ");
setTableCounts(tableCounts);
setTableCountNumbers(tableCountNumbers);
setTableCountTexts(tableCountTexts);
}
});
setTableCounts(tableCounts);
@ -203,6 +180,9 @@ 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) =>
@ -220,80 +200,13 @@ function AppHome({app}: Props): JSX.Element
return reports.find(r => r.name === reportName && r.hasPermission);
};
const widgetCount = widgets ? widgets.length : 0;
const sectionCount = app.sections ? app.sections.length : 0;
//////////////////////////////////////////////////////////////////////////////////////////////////////
// if our app has no widgets or sections, but it does have child apps, then return those child apps //
//////////////////////////////////////////////////////////////////////////////////////////////////////
if(widgetCount == 0 && sectionCount == 0 && childApps && childApps.length > 0)
{
return (
<BaseLayout>
{
showAppLabelOnHomeScreen &&
<Typography textTransform="capitalize" variant="h3">
{app.label}
</Typography>
}
<Grid container spacing={3}>
<Grid item xs={12} lg={12}>
<Card sx={{overflow: "visible"}}>
<Box p={3} display="flex" alignItems="center" gap=".5rem">
<Typography variant="h5">Apps</Typography>
</Box>
<Grid container spacing={3} padding={3} pt={0}>
{childApps.map((childApp) => (
<Grid key={childApp.name} item xs={12} lg={3}>
<Link to={childApp.name}>
<Card>
<Box display="flex" alignItems="center" p={2}>
<Box
color={"#FFFFFF"}
display="flex"
justifyContent="center"
alignItems="center"
width="4rem"
height="4rem"
sx={{borderRadius: "10px", backgroundColor: colors.info.main}}
>
<Icon fontSize="medium" color="inherit">
{childApp.iconName || app.iconName}
</Icon>
</Box>
<Box textAlign="left" ml={2}>
<MDTypography variant="button" fontWeight="bold" color="text">
{childApp.label}
</MDTypography>
</Box>
</Box>
</Card>
</Link>
</Grid>
))}
</Grid>
</Card>
</Grid>
</Grid>
</BaseLayout>
)
}
return (
<BaseLayout>
{
showAppLabelOnHomeScreen &&
<Typography textTransform="capitalize" variant="h3">
{app.label}
</Typography>
}
<Box>
{app.widgets && app.widgets.length > 0 && (
<Box pb={app.sections ? 2.375 : 4} pt={"0.5rem"}>
{app.widgets && (
<DashboardWidgets widgetMetaDataList={widgets} />
</Box>
)}
<Grid container spacing={3} mt={"-1rem"}>
<Grid container spacing={3}>
{
app.sections ? (
<Grid item xs={12} lg={12}>
@ -313,7 +226,7 @@ function AppHome({app}: Props): JSX.Element
</Box>
{
section.processes ? (
<Box p={3} pl={3} pt={0} pb={1}>
<Box p={3} pl={5} pt={0} pb={1}>
<MDTypography variant="h6">Actions</MDTypography>
</Box>
) : null
@ -354,7 +267,7 @@ function AppHome({app}: Props): JSX.Element
}
{
section.reports ? (
<Box p={3} pl={3} pt={0} pb={1}>
<Box p={3} pl={5} pt={0} pb={1}>
<MDTypography variant="h6">Reports</MDTypography>
</Box>
) : null
@ -397,7 +310,7 @@ function AppHome({app}: Props): JSX.Element
}
{
section.tables ? (
<Box p={3} pl={3} pb={1} pt={0}>
<Box p={3} pl={5} pb={1} pt={0}>
<MDTypography variant="h6">Data</MDTypography>
</Box>
) : null
@ -409,13 +322,6 @@ function AppHome({app}: Props): JSX.Element
section.tables.map((tableName) =>
{
let table = app.childMap.get(tableName);
let count = "";
let percentage = "";
if(includeTableCountsOnHomeScreen)
{
count = !tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name));
percentage = !tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "" : (tableCountTexts.get(table.name));
}
return (
<Grid key={table.name} item xs={12} md={12} lg={tileSizeLg}>
{hasTablePermission(tableName) ?
@ -423,8 +329,8 @@ function AppHome({app}: Props): JSX.Element
<Box className="big-icon" mb={3}>
<MiniStatisticsCard
title={{fontWeight: "bold", text: table.label}}
count={count}
percentage={{color: "info", text: percentage}}
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
percentage={{color: "info", text: (!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "" : (tableCountTexts.get(table.name)))}}
icon={{color: "info", component: <Icon>{table.iconName || app.iconName}</Icon>}}
/>
</Box>
@ -432,8 +338,8 @@ function AppHome({app}: Props): JSX.Element
<Box mb={3} title="You do not have permission to access this table">
<MiniStatisticsCard
title={{fontWeight: "bold", text: table.label}}
count={count}
percentage={{color: "info", text: percentage}}
count={!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "..." : (tableCountNumbers.get(table.name))}
percentage={{color: "info", text: (!tableCounts.has(table.name) || tableCounts.get(table.name).isLoading ? "" : (tableCountTexts.get(table.name)))}}
icon={{color: "info", component: <Icon>{table.iconName || app.iconName}</Icon>}}
isDisabled={true}
/>

View File

@ -229,7 +229,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const download = (url: string, fileName: string) =>
{
/////////////////////////////////////////////////////////////////////////////////////////////
// todo - this could be simplified, i think? //
// todo - this could be simplified. //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
let xhr = new XMLHttpRequest();
@ -237,6 +237,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
xhr.responseType = "blob";
let formData = new FormData();
////////////////////////////////////
// todo#authHeader - delete this. //
////////////////////////////////////
const qController = Client.getInstance();
formData.append("Authorization", qController.getAuthorizationHeaderValue());
// @ts-ignore
xhr.send(formData);
@ -323,7 +329,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
<Grid m={3} mt={9} container>
<Grid item xs={0} lg={3} />
<Grid item xs={12} lg={6}>
<Card>
<Card elevation={5}>
<Box p={3}>
<MDTypography variant="h5" component="div">
Working
@ -414,15 +420,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
//////////////////////////////////////////////////
// render all of the components for this screen //
//////////////////////////////////////////////////
step.components && (step.components.map((component: QFrontendComponent, index: number) =>
{
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]
if(component.type == QComponentType.BULK_EDIT_FORM)
{
helpRoles = ["EDIT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
}
return (
step.components && (step.components.map((component: QFrontendComponent, index: number) => (
<div key={index}>
{
component.type === QComponentType.HELP_TEXT && (
@ -462,8 +460,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
}
</Grid>
<Grid item xs={12} lg={9}>
{
localTableSections.map((section: QTableSection, index: number) =>
{localTableSections.map((section: QTableSection, index: number) =>
{
const name = section.name;
@ -499,7 +497,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{section.label}
</MDTypography>
<Box px={2}>
<QDynamicForm formData={sectionFormData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableMetaData?.name};`} />
<QDynamicForm formData={sectionFormData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
</Box>
</Card>
</Box>
@ -509,16 +507,16 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{
return (<br />);
}
})
}
)}
</Grid>
</Grid>
: <QDynamicForm formData={formData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableMetaData?.name};`} />
: <QDynamicForm formData={formData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
)
}
{
component.type === QComponentType.EDIT_FORM && (
<QDynamicForm formData={formData} helpRoles={helpRoles} helpContentKeyPrefix={`process:${processName};`} />
<QDynamicForm formData={formData} />
)
}
{
@ -643,9 +641,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
)
}
</div>
);
}))
}
)))}
</>
);
};
@ -1113,7 +1109,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (recordIds instanceof QQueryFilter)
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${encodeURIComponent(JSON.stringify(recordIds))}`);
queryStringPairsForInit.push(`filterJSON=${JSON.stringify(recordIds)}`);
}
else if (typeof recordIds === "object" && recordIds.length)
{
@ -1126,7 +1122,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
{
let tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
queryStringPairsForInit.push(`tableVariant=${encodeURIComponent(JSON.stringify(tableVariant))}`);
queryStringPairsForInit.push(`tableVariant=${JSON.stringify(tableVariant)}`);
}
try
@ -1281,14 +1277,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
mainCardStyles.minHeight = `calc(100vh - ${isModal ? 150 : 400}px)`;
if (!processError && (qJobRunning || activeStep === null) && !isModal && !isWidget)
{
mainCardStyles.background = "#FFFFFF";
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
}
if (isWidget)
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
mainCardStyles.border = "none";
mainCardStyles.minHeight = "";
mainCardStyles.alignItems = "stretch";
mainCardStyles.flexGrow = 1;

View File

@ -34,8 +34,12 @@ function EntityCreate({table}: Props): JSX.Element
{
return (
<BaseLayout>
<Box mb={3}>
<Box mt={4}>
<Grid container spacing={3}>
<Grid item xs={12} lg={12}>
<EntityForm table={table} />
</Grid>
</Grid>
</Box>
</BaseLayout>
);

View File

@ -43,8 +43,18 @@ function EntityEdit({table, isCopy}: Props): JSX.Element
return (
<BaseLayout>
<Box mt={4}>
<Grid container spacing={3}>
<Grid item xs={12} lg={12}>
<Box mb={3}>
<Grid container spacing={3}>
<Grid item xs={12}>
<EntityForm table={table} id={id} isCopy={isCopy} />
</Grid>
</Grid>
</Box>
</Grid>
</Grid>
</Box>
</BaseLayout>
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -190,7 +190,7 @@ function RecordDeveloperView({table}: Props): JSX.Element
return (
<div key={fieldName}>
<Card sx={{mb: 3}}>
<Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography>
<Typography variant="h6" p={2} pl={3} pb={1}>{field?.label}</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}>
{scriptId ?

Some files were not shown because too many files have changed in this diff Show More