Compare commits

..

1 Commits

42 changed files with 716 additions and 1297 deletions

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.83",
"@kingsrook/qqq-frontend-core": "1.0.82",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",

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>

View File

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

View File

@ -1,31 +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";
}

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 ${rgba(black.main, 0.25)}`,
border: `${borderWidth[0]} solid ${rgba(black.main, 0.125)}`,
borderRadius: borderRadius.xl,
overflow: "visible",
},

View File

@ -1,72 +1,68 @@
/**
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
Coded by www.creative-tim.com
Coded by www.creative-tim.com
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// 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 { grey, white } = colors;
const { borderRadius } = borders;
const { tabsBoxShadow } = boxShadows;
// types
type Types = any;
const tabs: Types = {
styleOverrides: {
root: {
position: "relative",
borderRadius: 0,
borderBottom: "1px solid",
borderBottomColor: grey[400],
minHeight: "unset",
padding: "0",
margin: "0"
},
styleOverrides: {
root: {
position: "relative",
backgroundColor: grey[100],
borderRadius: borderRadius.xl,
minHeight: "unset",
padding: pxToRem(4),
},
scroller: {
marginLeft: "0.5rem"
},
flexContainer: {
height: "100%",
position: "relative",
zIndex: 10,
},
flexContainer: {
height: "100%",
position: "relative",
width: "fit-content",
zIndex: 10,
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
},
},
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
},
},
indicator: {
height: "100%",
borderRadius: 0,
backgroundColor: white.main,
borderBottom: "2px solid",
borderBottomColor: colors.info.main,
transition: "all 500ms ease",
},
},
indicator: {
height: "100%",
borderRadius: borderRadius.lg,
backgroundColor: white.main,
boxShadow: tabsBoxShadow.indicator,
transition: "all 500ms ease",
},
},
};
export default tabs;

View File

@ -1,17 +1,17 @@
/**
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
Coded by www.creative-tim.com
Coded by www.creative-tim.com
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Material Dashboard 2 PRO React TS Base Styles
import typography from "qqq/assets/theme/base/typography";
@ -21,50 +21,48 @@ import colors from "qqq/assets/theme/base/colors";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const {size, fontWeightRegular} = typography;
const {borderRadius} = borders;
const {dark} = colors;
const { size, fontWeightRegular } = typography;
const { borderRadius } = borders;
const { dark } = colors;
// types
type Types = any;
const tab: Types = {
styleOverrides: {
root: {
display: "flex",
alignItems: "center",
flexDirection: "row",
flex: "1 1 auto",
textAlign: "center",
maxWidth: "unset !important",
minWidth: "unset !important",
minHeight: "unset !important",
fontSize: size.md,
fontWeight: fontWeightRegular,
textTransform: "none",
lineHeight: "inherit",
padding: "0.75rem 0.5rem 0.5rem",
margin: "0 0.5rem",
borderRadius: 0,
border: 0,
color: `${dark.main} !important`,
opacity: "1 !important",
styleOverrides: {
root: {
display: "flex",
alignItems: "center",
flexDirection: "row",
flex: "1 1 auto",
textAlign: "center",
maxWidth: "unset !important",
minWidth: "unset !important",
minHeight: "unset !important",
fontSize: size.md,
fontWeight: fontWeightRegular,
textTransform: "none",
lineHeight: "inherit",
padding: pxToRem(4),
borderRadius: borderRadius.lg,
color: `${dark.main} !important`,
opacity: "1 !important",
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
labelIcon: {
paddingTop: pxToRem(4),
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
},
},
labelIcon: {
paddingTop: pxToRem(4),
},
},
};
export default tab;

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: "rgba(0, 0, 0, 0.2) 0px 3px 3px -2px, rgba(0, 0, 0, 0.14) 0px 3px 4px 0px, rgba(0, 0, 0, 0.12) 0px 1px 8px 0px"
padding: `${pxToRem(5)} ${pxToRem(8)} ${pxToRem(4)}`,
},
arrow: {
color: white.main,
color: black.main,
},
},
};

View File

@ -19,14 +19,13 @@
* 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: 28,
@ -61,9 +60,6 @@ const AntSwitch = styled(Switch)(({theme}) => ({
duration: 200,
}),
},
"&.nullSwitch .MuiSwitch-thumb": {
width: 24,
},
"& .MuiSwitch-track": {
borderRadius: 16 / 2,
opacity: 1,
@ -82,7 +78,6 @@ interface Props
}
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -101,10 +96,8 @@ 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">
<Typography
@ -114,7 +107,7 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
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="0.875rem"
color={value === true ? "auto" : "#bfbfbf"}
@ -123,7 +116,7 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
Yes
</Typography>
</Stack>
</Box>
</>
);
}

View File

@ -88,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")
{

View File

@ -426,11 +426,6 @@ function EntityForm(props: Props): JSX.Element
actions.setSubmitting(true);
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);
@ -443,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]);
}
}
@ -466,10 +461,10 @@ 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]);
delete(values[fieldName]);
}
}
}
@ -478,7 +473,7 @@ function EntityForm(props: Props): JSX.Element
{
// todo - audit that it's a dupe
await qController
.update(tableName, props.id, valuesToPost)
.update(tableName, props.id, values)
.then((record) =>
{
if (props.isModal)
@ -511,7 +506,7 @@ function EntityForm(props: Props): JSX.Element
else
{
await qController
.create(tableName, valuesToPost)
.create(tableName, values)
.then((record) =>
{
if (props.isModal)

View File

@ -19,7 +19,7 @@
* 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";
@ -34,8 +34,8 @@ 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 {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
@ -57,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);
@ -105,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);
@ -164,15 +162,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
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}>
@ -185,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},
@ -234,22 +240,26 @@ 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} mt={-4}>
<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>

View File

@ -110,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",
},
@ -145,27 +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) => ({
"& .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,
@ -181,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 = 275;
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

@ -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";
@ -195,8 +196,17 @@ function GotoRecordDialog(props: Props): JSX.Element
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}
{

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

@ -22,13 +22,11 @@ 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";
@ -46,7 +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 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";
@ -60,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 = {
@ -73,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);
@ -87,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([]);
@ -123,15 +102,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData[i] = await qController.widget(widgetMetaData.name, urlParams);
setWidgetData(widgetData);
setWidgetCounter(widgetCounter + 1);
if (widgetData[i])
if(widgetData[i])
{
widgetData[i]["errorLoading"] = false;
}
}
catch (e)
catch(e)
{
console.error(e);
if (widgetData[i])
if(widgetData[i])
{
widgetData[i]["errorLoading"] = true;
}
@ -144,7 +123,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const reloadWidget = async (index: number, data: string) =>
{
(async () =>
(async() =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -161,7 +140,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData[index]["errorLoading"] = false;
}
}
catch (e)
catch(e)
{
console.error(e);
if (widgetData[index])
@ -172,7 +151,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
forceUpdate();
})();
};
}
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
{
@ -199,36 +178,36 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
}
if (entityPrimaryKey)
if(entityPrimaryKey)
{
paramMap.set("id", entityPrimaryKey);
}
if (tableName)
if(tableName)
{
paramMap.set("tableName", tableName);
}
if (extraParams)
if(extraParams)
{
let pairs = extraParams.split("&");
for (let i = 0; i < pairs.length; i++)
{
let nameValue = pairs[i].split("=");
if (nameValue.length == 2)
if(nameValue.length == 2)
{
paramMap.set(nameValue[0], nameValue[1]);
}
}
}
if (childUrlParams)
if(childUrlParams)
{
let pairs = childUrlParams.split("&");
for (let i = 0; i < pairs.length; i++)
{
let nameValue = pairs[i].split("=");
if (nameValue.length == 2)
if(nameValue.length == 2)
{
paramMap.set(nameValue[0], nameValue[1]);
}
@ -248,16 +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.color));
}
}
return (
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
{
@ -269,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}
/>
)
@ -301,9 +270,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
>
<StackedBarChart data={widgetData[i]?.chartData} chartSubheaderData={widgetData[i]?.chartSubheaderData} />
<StackedBarChart data={widgetData[i]?.chartData}/>
</Widget>
)
}
@ -413,12 +381,10 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
>
<div>
<PieChart
chartData={widgetData[i]?.chartData}
chartSubheaderData={widgetData[i]?.chartSubheaderData}
description={widgetData[i]?.description}
/>
</div>
@ -470,11 +436,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
{
widgetMetaData.type === "fieldValueList" && (
widgetData && widgetData[i] &&
<FieldValueListWidget
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
/>
<FieldValueListWidget
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
/>
)
}
{
@ -495,62 +461,32 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
</Box>
);
};
}
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: "1rem 0 0 1.5rem", width: "100%", marginBottom: "-1.5rem"}}>
{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}}
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={3} pb={4}>
{body}
</Grid>
)
}
</>
omitWrappingGridContainer ? body :
(
<Grid container spacing={3} pb={4}>
{body}
</Grid>
)
) : null
);
}

View File

@ -43,7 +43,6 @@ export interface ParentWidgetData
dropdownNeedsSelectedText?: string;
storeDropdownSelections?: boolean;
icon?: string;
layoutType: string;
}
@ -56,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;
@ -92,15 +91,10 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
}
}, [qInstance, data, childUrlParams]);
useEffect(() =>
{
setChildUrlParams(urlParams)
}, [urlParams]);
const parentReloadWidgetCallback = (data: string) =>
{
setChildUrlParams(data);
reloadWidgetCallback(data);
reloadWidgetCallback(widgetIndex, data);
}
///////////////////////////////////////////////////////////////////////////////////////////
@ -118,7 +112,7 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
reloadWidgetCallback={parentReloadWidgetCallback}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType == "TABS"}/>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData}/>
</Box>
</Widget>
) : null

View File

@ -30,7 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import React, {useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
@ -46,7 +46,6 @@ export interface WidgetData
dropdownNeedsSelectedText?: string;
hasPermission?: boolean;
errorLoading?: boolean;
[other: string]: any;
}
@ -54,7 +53,6 @@ export interface WidgetData
interface Props
{
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
@ -72,7 +70,6 @@ Widget.defaultProps = {
widgetMetaData: {},
widgetData: {},
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
};
@ -91,50 +88,33 @@ 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;
color: string;
coloredBG: boolean;
label: string;
to: string
iconColor: string;
bgColor: string;
constructor(iconName: string, color: string, coloredBG: boolean = true)
constructor(label: string, to: string)
{
super();
this.iconName = iconName;
this.color = color;
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 =>
{
return (
<Icon sx={{
m: 2,
mr: 0,
mb: 0,
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
backgroundColor: this.bgColor,
padding: "0.25rem",
borderRadius: "0.25rem"
}} fontSize="small">{this.iconName}</Icon>
)
<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>
);
}
}
@ -161,8 +141,8 @@ export class AddNewRecordButton extends LabelComponent
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 =>
{
@ -171,7 +151,35 @@ export class AddNewRecordButton extends LabelComponent
<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>
);
}
}
@ -219,7 +227,7 @@ export class Dropdown extends LabelComponent
/>
</Box>
);
};
}
}
@ -243,7 +251,7 @@ export class ReloadControl extends LabelComponent
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
</Typography>
);
};
}
}
@ -364,7 +372,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (index < 0)
{
throw (`Could not find table name for label ${tableName}`);
throw(`Could not find table name for label ${tableName}`);
}
dropdownData[index] = (changedData) ? changedData.id : null;
@ -386,7 +394,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
}
reloadWidget(dropdownData);
reloadWidget(dropdownData)
}
}
@ -414,7 +422,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
}
};
}
const toggleFullScreenWidget = () =>
{
@ -426,14 +434,14 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
setFullScreenWidgetClassName("fullScreenWidget");
}
};
}
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
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 //
@ -442,42 +450,24 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission)
{
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (props.labelAdditionalElementsLeft && props.labelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
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 //
// note - previously this had a ?: and one was pl={2}, the other was pl={3}... //
//////////////////////////////////////////////////////////////////////////////////////////
const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label
let labelElement = (
<Typography sx={{position: "relative", top: -4, cursor: "default"}} variant="h6" fontWeight="medium" display="inline">
{labelToUse}
</Typography>
);
if(props.widgetMetaData.tooltip)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={false} followCursor={true} placement="bottom-start">{labelElement}</Tooltip>
}
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 pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} minHeight={"3.5rem"}>
<Box pt={2} pb={1} ml={2}>
<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}
ml={3}
mt={-4}
sx={{
display: "flex",
@ -517,7 +507,20 @@ 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 && (
@ -527,7 +530,6 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
})
)
}
{props.labelAdditionalElementsLeft}
</Box>
<Box>
{
@ -577,7 +579,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
{widgetContent}
</Card>
: <span style={{width: "100%"}}>{widgetContent}</span>;
: widgetContent;
}
export default Widget;

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,
@ -40,40 +39,18 @@ ChartJS.register(
);
export const options = {
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
plugins: {
tooltip: {
// todo - some configs around this
enabled: false
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 8,
boxWidth: 8,
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}
},
},
};
@ -81,12 +58,10 @@ export const options = {
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();
@ -95,30 +70,23 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
const handleClick = (e: Array<{}>) =>
{
if (e && e.length > 0 && data?.urls && data?.urls.length)
if(e && e.length > 0 && data?.urls && data?.urls.length)
{
// @ts-ignore
navigate(data.urls[e[0]["index"]]);
}
console.log(e);
};
}
useEffect(() =>
{
if (data)
if(data)
{
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];
}
dataset.backgroundColor = gradients[chartColors[index]].state;
}
});
setStateData(stateData);
@ -127,13 +95,8 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
return data ? (
<Box p={3} pt={1}>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={options} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;
<Box p={3}><Bar data={data} options={options} getElementsAtEvent={handleClick} /></Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} /> ;
}
export default StackedBarChart;

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,29 +51,25 @@ 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;
}
chartData.dataset.backgroundColors = chartColors;
}
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {});
useEffect(() =>
{
if (chartData)
if(chartData)
{
setDataLoaded(true);
}
@ -82,22 +77,19 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
const handleClick = (e: Array<{}>) =>
{
if (e && e.length > 0 && chartData?.dataset?.urls && chartData?.dataset?.urls.length)
if(e && e.length > 0 && chartData?.dataset?.urls && chartData?.dataset?.urls.length)
{
// @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 mt={1}>
<Box px={3}>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
</Box>
<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="300px" py={2} pr={2} pl={2}>
<Box width="100%" height="80%" py={2} pr={2} pl={2}>
{useMemo(
() => (
<Pie data={data} options={options} getElementsAtEvent={handleClick} />
@ -106,35 +98,32 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
)}
</Box>
{
!chartData && (
! chartData && (
<Box sx={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
justifyContent: "center"
}}>
<Skeleton sx={{width: "150px", height: "150px"}} variant="circular" />
justifyContent: "center"}}>
<Skeleton sx={{width: "150px", height: "150px"}} variant="circular"/>
</Box>
)
}
</Grid>
</Grid>
<Divider />
{
description && (
<>
<Divider />
<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 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

@ -30,16 +30,10 @@ function configs(labels: any, datasets: any)
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,33 +58,12 @@ function configs(labels: any, datasets: any)
],
},
options: {
maintainAspectRatio: false,
maintainAspectRatio: true,
responsive: true,
aspectRatio: 2,
plugins: {
tooltip: {
callbacks: {
label: function(context: any)
{
////////////////////////////////////////////////////////////////////////////////
// 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!) //
////////////////////////////////////////////////////////////////////////////////
return context.label;
}
}
},
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="h2" 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="inline" variant="body2" sx={{color: colors.black.main}}>
&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="flex-end" pb={1} ml={-0.5}>
<Icon fontSize="medium" sx={{color: color}}>{iconName}</Icon>
<Typography display="inline" variant="body2" sx={{color: color}}>{chartSubheaderData.vsPreviousPercent}%</Typography>
{previousNumberElement}
</Box>
)
}
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "12px"}} />;
}
export default StackedBarChart;

View File

@ -286,15 +286,18 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Tabs
sx={{m: 0, mb: 1, mt: -3}}
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" />
</Tabs>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
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" 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,14 +22,10 @@
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 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 {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {useNavigate, Link} from "react-router-dom";
import Widget, {AddNewRecordButton, LabelComponent} from "qqq/components/widgets/Widget";
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";
@ -51,8 +47,6 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
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 navigate = useNavigate();
useEffect(() =>
@ -81,7 +75,6 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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)));
////////////////////////////////////////////////////////////////
@ -102,42 +95,39 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
setRows(rows);
setRecords(records)
setColumns(columns);
let csv = "";
for (let i = 0; i < allColumns.length; i++)
{
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`
}
csv += "\n";
for (let i = 0; i < records.length; i++)
{
for (let j = 0; j < allColumns.length; j++)
{
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field)
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`
}
csv += "\n";
}
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
setCsv(csv);
setFileName(fileName);
}
}, [data]);
const exportCallback = () =>
{
let csv = "";
for (let i = 0; i < allColumns.length; i++)
{
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`
}
csv += "\n";
for (let i = 0; i < records.length; i++)
{
for (let j = 0; j < allColumns.length; j++)
{
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field)
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`
}
csv += "\n";
}
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
HtmlUtils.download(fileName, csv);
}
///////////////////
// 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" top="-0.25rem">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink));
}
///////////////////
@ -159,26 +149,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
}
}
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" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle))
////////////////////
// add new button //
@ -213,7 +184,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
<Widget
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
>
<DataGridPro

View File

@ -430,17 +430,20 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Tabs
sx={{m: 0, mb: 1, mt: -3}}
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" />
</Tabs>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
sx={{m: 1}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
<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>
@ -495,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

@ -30,7 +30,7 @@ import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import parse from "html-react-parser";
import {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable, useExpanded} from "react-table";
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";
@ -47,8 +47,6 @@ interface Props
canSearch?: boolean;
showTotalEntries?: boolean;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
table: TableDataInput;
pagination?: {
variant: "contained" | "gradient";
@ -58,18 +56,6 @@ interface Props
noEndBorder?: boolean;
}
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}} />
))({
@ -85,8 +71,6 @@ function DataTable({
hidePaginationDropdown,
canSearch,
showTotalEntries,
fixedStickyLastRow,
fixedHeight,
table,
pagination,
isSorted,
@ -99,77 +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,
},
);
}
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)
{
@ -180,7 +95,6 @@ function DataTable({
{columns, data, initialState: {pageIndex: 0}},
useGlobalFilter,
useSortBy,
useExpanded,
usePagination
);
@ -199,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
@ -279,45 +193,79 @@ function DataTable({
entriesEnd = pageSize * (pageIndex + 1);
}
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
{
boxStyle = isFooter ? {overflowY: "visible", borderTop: "0.0625rem solid #f0f2f5;"} : {height: fixedHeight ? `${fixedHeight}px` : "360px", overflowY: "auto"};
}
const className = isFooter ? "hideScrollbars" : "";
return <Box sx={boxStyle} className={className}>
<Table {...getTableProps()}>
{
includeHead && (
<Box component="thead" sx={{position: "sticky", top: 0, background: "white"}}>
{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)}
>
{column.render("header")}
</DataTableHeadCell>
)
))}
</TableRow>
))}
return (
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && ! hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || ! hidePaginationDropdown) && (
<Box display="flex" alignItems="center">
<Autocomplete
disableClearable
value={pageSize.toString()}
options={entries}
onChange={(event, newValues: any) =>
{
if(typeof newValues === "string")
{
setEntriesPerPage(parseInt(newValues, 10));
}
else
{
setEntriesPerPage(parseInt(newValues[0], 10));
}
}}
size="small"
sx={{width: "5rem"}}
renderInput={(params) => <MDInput {...params} />}
/>
<MDTypography variant="caption" color="secondary">
&nbsp;&nbsp;entries per page
</MDTypography>
</Box>
)
}
)}
{canSearch && (
<Box width="12rem" ml="auto">
<MDInput
placeholder="Search..."
value={search}
size="small"
fullWidth
onChange={({currentTarget}: any) =>
{
setSearch(search);
onSearchChange(currentTarget.value);
}}
/>
</Box>
)}
</Box>
) : null}
<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()}>
{rows.map((row: any, key: any) =>
{page.map((row: any, key: any) =>
{
prepareRow(row);
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: (row.depth > 0 ? "#FAFAFA" : "initial")}} key={key} {...row.getRowProps()}>
<TableRow sx={{verticalAlign: "top"}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
@ -360,9 +308,6 @@ function DataTable({
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
@ -371,65 +316,6 @@ function DataTable({
})}
</TableBody>
</Table>
</Box>
}
return (
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (
<Box display="flex" alignItems="center">
<Autocomplete
disableClearable
value={pageSize.toString()}
options={entries}
onChange={(event, newValues: any) =>
{
if (typeof newValues === "string")
{
setEntriesPerPage(parseInt(newValues, 10));
}
else
{
setEntriesPerPage(parseInt(newValues[0], 10));
}
}}
size="small"
sx={{width: "5rem"}}
renderInput={(params) => <MDInput {...params} />}
/>
<MDTypography variant="caption" color="secondary">
&nbsp;&nbsp;entries per page
</MDTypography>
</Box>
)}
{canSearch && (
<Box width="12rem" ml="auto">
<MDInput
placeholder="Search..."
value={search}
size="small"
fullWidth
onChange={({currentTarget}: any) =>
{
setSearch(search);
onSearchChange(currentTarget.value);
}}
/>
</Box>
)}
</Box>
) : null}
{
fixedStickyLastRow ? (
<>
{getTable(true, page.slice(0, page.length -1), false)}
{getTable(false, page.slice(page.length-1), true)}
</>
) : getTable(true, page, false)
}
<Box
display="flex"
@ -482,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

@ -54,13 +54,11 @@ interface Props
noRowsFoundHTML?: string;
rowsPerPage?: number;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
data: TableDataInput;
}
const qController = Client.getInstance();
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown, fixedStickyLastRow, fixedHeight}: Props): JSX.Element
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}: Props): JSX.Element
{
const [qInstance, setQInstance] = useState(null as QInstance);
@ -81,8 +79,6 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
table={data}
entriesPerPage={rowsPerPage}
hidePaginationDropdown={hidePaginationDropdown}
fixedStickyLastRow={fixedStickyLastRow}
fixedHeight={fixedHeight}
showTotalEntries={false}
isSorted={false}
noEndBorder

View File

@ -21,15 +21,11 @@
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";
// @ts-ignore
import {htmlToText} from "html-to-text";
import React, {useEffect, useState} from "react";
import TableCard from "qqq/components/widgets/tables/TableCard";
import Widget, {WidgetData} from "qqq/components/widgets/Widget";
import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget";
import HtmlUtils from "qqq/utils/HtmlUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -47,8 +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 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++)
{
@ -98,37 +98,16 @@ function TableWidget(props: Props): JSX.Element
csv += "\n";
}
setCsv(csv);
console.log(csv);
const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
setFileName(fileName)
console.log(`useEffect, setting fileName ${fileName}`);
}
}, [props.widgetMetaData, props.widgetData]);
const onExportClick = () =>
{
if(csv)
{
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
alert("There is no data available to export.");
}
}
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(props.widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(
<Typography key={1} variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title="Export"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
};
return (
<Widget
@ -137,14 +116,12 @@ 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}}
/>
</Widget>

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
@ -1278,7 +1284,6 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
mainCardStyles.border = "none";
mainCardStyles.minHeight = "";
mainCardStyles.alignItems = "stretch";
mainCardStyles.flexGrow = 1;

View File

@ -55,7 +55,7 @@ import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, G
import {GridRowModel} from "@mui/x-data-grid/models/gridRows";
import FormData from "form-data";
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
import {Navigate, NavigateFunction, useLocation, useNavigate, useSearchParams} from "react-router-dom";
import QContext from "QContext";
import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import MenuButton from "qqq/components/buttons/MenuButton";
@ -752,26 +752,62 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
console.log(`Received error for query ${thisQueryId}`);
console.log(error);
////////////////////////////////////////////////////////////////////////////////////
// special case for variant errors, if 500 and certain message, just clear out //
// local storage of variant and reload the page (rather than black page of death) //
////////////////////////////////////////////////////////////////////////////////////
var errorMessage;
if (error && error.message)
if(tableMetaData?.usesVariants)
{
errorMessage = error.message;
}
else if (error && error.response && error.response.data && error.response.data.error)
{
errorMessage = error.response.data.error;
if (error.status == "500" && error.message.indexOf("Could not find Backend Variant") != -1)
{
if (table)
{
const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${table.name}`;
localStorage.removeItem(tableVariantLocalStorageKey);
}
}
else
{
if (error && error.message)
{
errorMessage = error.message;
}
else if (error && error.response && error.response.data && error.response.data.error)
{
errorMessage = error.response.data.error;
}
else
{
errorMessage = "Unexpected error running query";
}
setAlertContent(errorMessage);
setLoading(false);
}
}
else
{
errorMessage = "Unexpected error running query";
if (error && error.message)
{
errorMessage = error.message;
}
else if (error && error.response && error.response.data && error.response.data.error)
{
errorMessage = error.response.data.error;
}
else
{
errorMessage = "Unexpected error running query";
}
queryErrors[thisQueryId] = errorMessage;
setQueryErrors(queryErrors);
setReceivedQueryErrorTimestamp(new Date());
throw error;
}
queryErrors[thisQueryId] = errorMessage;
setQueryErrors(queryErrors);
setReceivedQueryErrorTimestamp(new Date());
throw error;
});
})
})();
};
@ -1136,6 +1172,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" >
<!-- todo#authHeader - remove this. -->
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">
</form>
@ -1887,7 +1925,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
return (
<BaseLayout>
<TableVariantDialog table={tableMetaData} isOpen={true} closeHandler={(value: QTableVariant) =>
<TableVariantDialog navigate={navigate} table={tableMetaData} isOpen={true} closeHandler={(value: QTableVariant) =>
{
setTableVariantPromptOpen(false);
setTableVariant(value);
@ -2017,7 +2055,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
page={pageNumber}
checkboxSelection
disableSelectionOnClick
autoHeight={false}
autoHeight
rows={rows}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
columns={columnsModel}
@ -2041,7 +2079,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
getRowId={(row) => row.__rowIndex}
selectionModel={rowSelectionModel}
hideFooterSelectedRowCount={true}
sx={{border: 0, height: "calc(100vh - 250px)"}}
/>
</Box>
</Card>
@ -2058,7 +2095,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
tableMetaData &&
<TableVariantDialog table={tableMetaData} isOpen={tableVariantPromptOpen} closeHandler={(value: QTableVariant) =>
<TableVariantDialog navigate={navigate} table={tableMetaData} isOpen={tableVariantPromptOpen} closeHandler={(value: QTableVariant) =>
{
setTableVariantPromptOpen(false);
setTableVariant(value);
@ -2090,7 +2127,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////////////////////////
// mini-component that is the dialog for the user to select a variant on tables with variant backends //
////////////////////////////////////////////////////////////////////////////////////////////////////////
function TableVariantDialog(props: {isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void})
function TableVariantDialog(props: {navigate: NavigateFunction, isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void})
{
const [value, setValue] = useState(null)
const [dropDownOpen, setDropDownOpen] = useState(false)
@ -2139,7 +2176,17 @@ function TableVariantDialog(props: {isOpen: boolean; table: QTableMetaData; clos
return variants && (
<Dialog open={props.isOpen} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>{props.table.variantTableLabel}</DialogTitle>
<DialogTitle sx={{display: "flex"}}>
<Box sx={{display: "flex", flexGrow: 1}}>
{props.table.variantTableLabel}
</Box>
<Box sx={{display: "flex"}}>
<IconButton onClick={() =>
{
document.location.href = "/";
}}><Icon sx={{align: "right"}} fontSize="small">close</Icon></IconButton>
</Box>
</DialogTitle>
<DialogContent>
<DialogContentText>Select the {props.table.variantTableLabel} to be used on this table:</DialogContentText>
<Autocomplete

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 ?

View File

@ -441,17 +441,20 @@ function RecordView({table, launchProcess}: Props): JSX.Element
}
}
setPageHeader(record.recordLabel);
if(!launchingProcess)
if(record)
{
try
setPageHeader(record.recordLabel);
if(!launchingProcess)
{
HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName});
}
catch(e)
{
console.error("Error pushing history: " + e);
try
{
HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName});
}
catch(e)
{
console.error("Error pushing history: " + e);
}
}
}

View File

@ -564,14 +564,3 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
display: inline;
right: .5rem
}
.hideScrollbars::-webkit-scrollbar {
background: transparent; /* Chrome/Safari/Webkit */
width: 0;
}
.hideScrollbars {
padding-right: 8px; /* pad-right for about half the width of a scrollbar.. */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}

View File

@ -63,10 +63,6 @@ export default class HtmlUtils
/*******************************************************************************
** Download a server-side generated file (or the contents of a data: url)
**
** todo - this could be simplified (i think?)
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/
static downloadUrlViaIFrame = (url: string, filename: string) =>
{
@ -99,6 +95,18 @@ export default class HtmlUtils
form.setAttribute("target", "downloadIframe");
iframe.appendChild(form);
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const authorizationInput = document.createElement("input");
authorizationInput.setAttribute("type", "hidden");
authorizationInput.setAttribute("id", "authorizationInput");
authorizationInput.setAttribute("name", "Authorization");
authorizationInput.setAttribute("value", Client.getInstance().getAuthorizationHeaderValue());
form.appendChild(authorizationInput);
const downloadInput = document.createElement("input");
downloadInput.setAttribute("type", "hidden");
downloadInput.setAttribute("name", "download");
@ -110,16 +118,15 @@ export default class HtmlUtils
/*******************************************************************************
** Open a server-side generated file from a url in a new window (or a data: url)
**
** todo - this could be simplified (i think?)
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/
static openInNewWindow = (url: string, filename: string) =>
{
if(url.startsWith("data:"))
{
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove the Authorization input after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const openInWindow = window.open("", "_blank");
openInWindow.document.write(`<html lang="en">
@ -147,6 +154,7 @@ export default class HtmlUtils
<body>
Opening ${filename}...
<form id="exportForm" method="post" action="${url}" >
<input type="hidden" name="Authorization" value="${Client.getInstance().getAuthorizationHeaderValue()}">
</form>
</body>
</html>`);

View File

@ -29,18 +29,11 @@ import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException
class Client
{
private static qController: QController;
private static unauthorizedCallback: () => void;
private static handleException(exception: QException)
{
// todo - check for 401 and clear cookie et al & logout?
console.log(`Caught Exception: ${JSON.stringify(exception)}`);
if(exception && exception.status == "401" && Client.unauthorizedCallback)
{
console.log("This is a 401 - calling the unauthorized callback.");
Client.unauthorizedCallback();
}
throw (exception);
}
@ -53,11 +46,6 @@ class Client
return this.qController;
}
static setUnauthorizedCallback(unauthorizedCallback: () => void)
{
Client.unauthorizedCallback = unauthorizedCallback;
}
}
export default Client;

View File

@ -1,11 +1,6 @@
package com.kingsrook.qqq.materialdashboard.lib;
import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.junit.jupiter.api.AfterEach;
@ -16,7 +11,6 @@ import org.openqa.selenium.Dimension;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import static org.junit.jupiter.api.Assertions.fail;
/*******************************************************************************
@ -60,15 +54,7 @@ public class QBaseSeleniumTest
@BeforeEach
public void beforeEach()
{
manageDownloadsDirectory();
HashMap<String, Object> chromePrefs = new HashMap<>();
chromePrefs.put("profile.default_content_settings.popups", 0);
chromePrefs.put("download.default_directory", getDownloadsDirectory());
chromeOptions.setExperimentalOption("prefs", chromePrefs);
driver = new ChromeDriver(chromeOptions);
driver.manage().window().setSize(new Dimension(1700, 1300));
qSeleniumLib = new QSeleniumLib(driver);
@ -82,57 +68,6 @@ public class QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
private void manageDownloadsDirectory()
{
File downloadsDirectory = new File(getDownloadsDirectory());
if(!downloadsDirectory.exists())
{
if(!downloadsDirectory.mkdir())
{
fail("Could not create downloads directory: " + downloadsDirectory);
}
}
if(!downloadsDirectory.isDirectory())
{
fail("Downloads directory: " + downloadsDirectory + " is not a directory.");
}
for(File file : CollectionUtils.nonNullArray(downloadsDirectory.listFiles()))
{
if(!file.delete())
{
fail("Could not remove a file from the downloads directory: " + file.getAbsolutePath());
}
}
}
/*******************************************************************************
**
*******************************************************************************/
protected String getDownloadsDirectory()
{
return ("/tmp/selenium-downloads");
}
/*******************************************************************************
**
*******************************************************************************/
protected List<File> getDownloadedFiles()
{
File[] downloadedFiles = CollectionUtils.nonNullArray((new File(getDownloadsDirectory())).listFiles());
return (Arrays.stream(downloadedFiles).toList());
}
/*******************************************************************************
** control if the test needs to start its own javalin server, or if we're running
** in an environment where an external web server is being used.

View File

@ -1,24 +1,3 @@
/*
* 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.materialdashboard.lib;
@ -27,15 +6,11 @@ import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
@ -61,8 +36,6 @@ public class QSeleniumLib
private boolean SCREENSHOTS_ENABLED = true;
private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/";
private boolean autoHighlight = false;
/*******************************************************************************
@ -214,13 +187,7 @@ public class QSeleniumLib
*******************************************************************************/
public WebElement waitForSelector(String cssSelector)
{
WebElement element = waitForSelectorAll(cssSelector, 1).get(0);
Actions actions = new Actions(driver);
actions.moveToElement(element);
conditionallyAutoHighlight(element);
return element;
return (waitForSelectorAll(cssSelector, 1).get(0));
}
@ -263,7 +230,7 @@ public class QSeleniumLib
do
{
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.isEmpty())
if(elements.size() == 0)
{
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return;
@ -289,7 +256,7 @@ public class QSeleniumLib
do
{
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.isEmpty())
if(elements.size() == 0)
{
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return;
@ -363,22 +330,6 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
private void soonUnhighlightElement(WebElement element)
{
CompletableFuture.supplyAsync(() ->
{
SleepUtils.sleep(2, TimeUnit.SECONDS);
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("arguments[0].setAttribute('style', 'background: unset; border: unset;');", element);
return (true);
});
}
/*******************************************************************************
**
*******************************************************************************/
@ -429,10 +380,7 @@ public class QSeleniumLib
@FunctionalInterface
public interface Code<T>
{
/*******************************************************************************
**
*******************************************************************************/
T run();
public T run();
}
@ -482,7 +430,6 @@ public class QSeleniumLib
LOG.debug("Found element matching selector [" + cssSelector + "] containing text [" + textContains + "].");
Actions actions = new Actions(driver);
actions.moveToElement(element);
conditionallyAutoHighlight(element);
return (element);
}
}
@ -490,10 +437,6 @@ public class QSeleniumLib
{
LOG.debug("Caught a StaleElementReferenceException - will retry.");
}
catch(NoSuchElementException nsee)
{
LOG.debug("Caught a NoSuchElementException - will retry.");
}
}
sleepABit();
@ -506,20 +449,6 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
private void conditionallyAutoHighlight(WebElement element)
{
if(autoHighlight && System.getenv("CIRCLECI") == null)
{
highlightElement(element);
soonUnhighlightElement(element);
}
}
/*******************************************************************************
** Take a screenshot, putting it in the SCREENSHOTS_PATH, with a subdirectory
** for the test class simple name, filename = methodName.png.
@ -549,8 +478,7 @@ public class QSeleniumLib
destFile.mkdirs();
if(destFile.exists())
{
String newFileName = destFile.getAbsolutePath().replaceFirst("\\.png", "-" + System.currentTimeMillis() + ".png");
destFile.renameTo(new File(newFileName));
destFile.delete();
}
FileUtils.moveFile(outputFile, destFile);
LOG.info("Made screenshot at: " + destFile);
@ -627,48 +555,4 @@ public class QSeleniumLib
}
}
/*******************************************************************************
**
*******************************************************************************/
public String getLatestChromeDownloadedFileInfo()
{
driver.get("chrome://downloads/");
JavascriptExecutor js = (JavascriptExecutor) driver;
WebElement element = (WebElement) js.executeScript("return document.querySelector('downloads-manager').shadowRoot.querySelector('#mainContainer > iron-list > downloads-item').shadowRoot.querySelector('#content')");
return (element.getText());
}
/*******************************************************************************
** Getter for autoHighlight
*******************************************************************************/
public boolean getAutoHighlight()
{
return (this.autoHighlight);
}
/*******************************************************************************
** Setter for autoHighlight
*******************************************************************************/
public void setAutoHighlight(boolean autoHighlight)
{
this.autoHighlight = autoHighlight;
}
/*******************************************************************************
** Fluent setter for autoHighlight
*******************************************************************************/
public QSeleniumLib withAutoHighlight(boolean autoHighlight)
{
this.autoHighlight = autoHighlight;
return (this);
}
}

View File

@ -29,9 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Test that goes to a record, clicks a link for another record, then
** hits 'e' on keyboard to edit the second record - and confirms that we're
** on the edit url for the second record, not the first (a former bug).
** Test for Associated Record Scripts functionality.
*******************************************************************************/
public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
{

View File

@ -1,111 +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/>.
*/
package com.kingsrook.qqq.materialdashboard.tests;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Tests for dashboard table widget with export button
*******************************************************************************/
public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/city/count", "data/city/count.json");
qSeleniumJavalin.withRouteToString("/widget/SampleTableWidget", """
{
"label": "Sample Table Widget",
"footerHTML": "<span class='material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit' aria-hidden='true'><span class='dashboard-schedule-icon'>schedule</span></span>Updated at 2023-10-17 09:11:38 AM CDT",
"columns": [
{ "type": "html", "header": "Id", "accessor": "id", "width": "1%" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "99%" }
],
"rows": [
{ "id": "1", "name": "<a href='/setup/person/1'>Homer S.</a>" },
{ "id": "2", "name": "<a href='/setup/person/2'>Marge B.</a>" },
{ "id": "3", "name": "<a href='/setup/person/3'>Bart J.</a>" }
],
"type": "table"
}
""");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDashboardTableWidgetExport() throws IOException
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App");
////////////////////////////////////////////////////////////////////////
// assert that the table widget rendered its header and some contents //
////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget h6", "Sample Table Widget");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget table a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget div", "Updated at 2023-10-17 09:11:38 AM CDT");
/////////////////////////////
// click the export button //
/////////////////////////////
qSeleniumLib.waitForSelector("#SampleTableWidget h6")
.findElement(By.xpath("./.."))
.findElement(By.cssSelector("button"))
.click();
qSeleniumLib.waitForCondition("Should have downloaded 1 file", () -> getDownloadedFiles().size() == 1);
File csvFile = getDownloadedFiles().get(0);
assertThat(csvFile.getName()).matches("Sample Table Widget.*.csv");
String fileContents = FileUtils.readFileToString(csvFile, StandardCharsets.UTF_8);
assertEquals("""
"Id","Name"
"1","Homer S."
"2","Marge B."
"3","Bart J."
""", fileContents);
// qSeleniumLib.waitForever();
}
}

View File

@ -302,7 +302,8 @@
"label": "Greetings App",
"iconName": "emoji_people",
"widgets": [
"SampleTableWidget"
"PersonsByCreateDateBarChart",
"QuickSightChartRenderer"
],
"children": [
{
@ -737,11 +738,139 @@
"icon": "/kr-icon.png"
},
"widgets": {
"SampleTableWidget": {
"name": "SampleTableWidget",
"label": "Sample Table Widget",
"parcelTrackingDetails": {
"name": "parcelTrackingDetails",
"label": "Tracking Details",
"type": "childRecordList"
},
"deposcoSalesOrderLineItems": {
"name": "deposcoSalesOrderLineItems",
"label": "Line Items",
"type": "childRecordList"
},
"TotalShipmentsByDayBarChart": {
"name": "TotalShipmentsByDayBarChart",
"label": "Total Shipments By Day",
"type": "chart"
},
"TotalShipmentsByMonthLineChart": {
"name": "TotalShipmentsByMonthLineChart",
"label": "Total Shipments By Month",
"type": "chart"
},
"YTDShipmentsByCarrierPieChart": {
"name": "YTDShipmentsByCarrierPieChart",
"label": "Shipments By Carrier Year To Date",
"type": "chart"
},
"TodaysShipmentsStatisticsCard": {
"name": "TodaysShipmentsStatisticsCard",
"label": "Today's Shipments",
"type": "statistics"
},
"ShipmentsInTransitStatisticsCard": {
"name": "ShipmentsInTransitStatisticsCard",
"label": "Shipments In Transit",
"type": "statistics"
},
"OpenOrdersStatisticsCard": {
"name": "OpenOrdersStatisticsCard",
"label": "Open Orders",
"type": "statistics"
},
"ShippingExceptionsStatisticsCard": {
"name": "ShippingExceptionsStatisticsCard",
"label": "Shipping Exceptions",
"type": "statistics"
},
"WarehouseLocationCards": {
"name": "WarehouseLocationCards",
"type": "location"
},
"TotalShipmentsStatisticsCard": {
"name": "TotalShipmentsStatisticsCard",
"label": "Total Shipments",
"type": "statistics"
},
"SuccessfulDeliveriesStatisticsCard": {
"name": "SuccessfulDeliveriesStatisticsCard",
"label": "Successful Deliveries",
"type": "statistics"
},
"ServiceFailuresStatisticsCard": {
"name": "ServiceFailuresStatisticsCard",
"label": "Service Failures",
"type": "statistics"
},
"CarrierVolumeLineChart": {
"name": "CarrierVolumeLineChart",
"label": "Carrier Volume By Month",
"type": "lineChart"
},
"YTDSpendByCarrierTable": {
"name": "YTDSpendByCarrierTable",
"label": "Spend By Carrier Year To Date",
"type": "table"
},
"TimeInTransitBarChart": {
"name": "TimeInTransitBarChart",
"label": "Time In Transit Last 30 Days",
"type": "chart"
},
"OpenBillingWorksheetsTable": {
"name": "OpenBillingWorksheetsTable",
"label": "Open Billing Worksheets",
"type": "table"
},
"AssociatedParcelInvoicesTable": {
"name": "AssociatedParcelInvoicesTable",
"label": "Associated Parcel Invoices",
"type": "table",
"showExportButton": true
"icon": "receipt"
},
"BillingWorksheetLinesTable": {
"name": "BillingWorksheetLinesTable",
"label": "Billing Worksheet Lines",
"type": "table"
},
"RatingIssuesWidget": {
"name": "RatingIssuesWidget",
"label": "Rating Issues",
"type": "html",
"icon": "warning",
"gridColumns": 6
},
"UnassignedParcelInvoicesTable": {
"name": "UnassignedParcelInvoicesTable",
"label": "Unassigned Parcel Invoices",
"type": "table"
},
"ParcelInvoiceSummaryWidget": {
"name": "ParcelInvoiceSummaryWidget",
"label": "Parcel Invoice Summary",
"type": "multiStatistics"
},
"ParcelInvoiceLineExceptionsSummaryWidget": {
"name": "ParcelInvoiceLineExceptionsSummaryWidget",
"label": "Parcel Invoice Line Exceptions",
"type": "multiStatistics"
},
"BillingWorksheetStatusStepper": {
"name": "BillingWorksheetStatusStepper",
"label": "Billing Worksheet Progress",
"type": "stepper",
"icon": "refresh",
"gridColumns": 6
},
"PersonsByCreateDateBarChart": {
"name": "PersonsByCreateDateBarChart",
"label": "Persons By Create Date",
"type": "barChart"
},
"QuickSightChartRenderer": {
"name": "QuickSightChartRenderer",
"label": "Quick Sight",
"type": "quickSightChart"
},
"scriptViewer": {
"name": "scriptViewer",