Compare commits

...

101 Commits

Author SHA1 Message Date
be393884cc CE-752 Final style updates for helpContent 2023-12-13 15:19:46 -06:00
fc5637b133 Update circleci/browser-tools version 2023-12-07 12:11:15 -06:00
8c7a7ae43e Update webdrivermanager version 2023-12-07 12:06:33 -06:00
ca83dbd83b make overflow w/ a max-height, since sticky. 2023-12-07 12:00:49 -06:00
68f652f3f3 Fixes for styles (spacing) in header record grid widget 2023-12-07 12:00:23 -06:00
adb2b4613d CE-752 Add help content concept to QQQ (fields and table sections at this time); redesign form fields (borders now) 2023-12-07 12:00:00 -06:00
c94f518422 Add padding under widgets if there are also app sections 2023-11-22 11:44:18 -06:00
d293db5136 Fixes to lineHeight (looks better when wraped (e.g., mobile)) 2023-11-22 11:43:52 -06:00
2ace79c08a Improve previous change here (overflow:hidden) to also do whiteSpace:wrap and fix lineHeight 2023-11-22 11:43:30 -06:00
c31db7ac32 CE-740 Remove margin above widget dropdowns (tighter when they stack) 2023-11-21 11:03:36 -06:00
dd887037c2 alt for user's avatar use proper user object 2023-11-21 11:03:08 -06:00
92516a2eb0 CE-740 Better table styles (scrolling, gutters, bg & border) 2023-11-21 11:02:55 -06:00
13a918441c CE-740 turn off trying to disable back & forth arrows 2023-11-21 08:59:57 -06:00
9e1c68b1fd Truncate long username rather than give horizontal scroll 2023-11-21 08:40:45 -06:00
d5c6985bc4 New style & functionality (null-label, default-from-data) for widget dropdowns 2023-11-21 08:37:43 -06:00
b8be374a01 Change tooltip to have value after colon, not in parens 2023-11-21 08:22:53 -06:00
2ef118a433 Fix small alignment of text 2023-11-21 08:22:34 -06:00
b1d685b5b1 CE-740 footer weight 600 vs body 500 2023-11-16 19:00:23 -06:00
87edebb79f CE-740 label weight 600 2023-11-16 18:29:27 -06:00
c722081ae7 CE-740 Fix body left margin 2023-11-16 18:15:21 -06:00
ca52466b79 CE-740 Reload button match export button color & size 2023-11-16 16:20:41 -06:00
dd45079ecd CE-740 export button standard color 2023-11-16 16:20:25 -06:00
aa7f9e93f1 CE-740 Border & tooltip for the current use-case 2023-11-16 16:20:11 -06:00
c899e5712b CE-740 Removing reduant padding 2023-11-16 12:19:47 -06:00
2a8bed1093 CE-740 Undo padding-left for non-card labels... i want it for labels on parents, but it made wrong inside-tabs... 2023-11-16 10:12:45 -06:00
627dd3c9f5 CE-740 more widget padding changes 2023-11-16 10:01:20 -06:00
40ac89dac3 CE-740 more SF Pro Display 2023-11-16 10:00:14 -06:00
e9223a1c23 Try to make test more robust (try-again on filename in case it catches the .crdownload file) 2023-11-16 09:59:59 -06:00
eeb121ff12 CE-740 more SF Pro Display 2023-11-16 09:55:47 -06:00
455869c96b CE-740 Actually ran test to get h3 & h5's right 2023-11-16 08:33:21 -06:00
90861b33a4 CE-740 Change h5 expects to h3 2023-11-16 08:16:15 -06:00
6be18627a7 CE-740 Update BREADCRUMB_HEADER selector (h5 became h3) 2023-11-15 20:11:14 -06:00
2255451745 CE-740 Update HeaderIcon to support icon from path (file) instead of name; padding adjustments (put 24/16 on the cards) 2023-11-15 19:59:58 -06:00
f9b29e932a CE-740 Add SF Pro Display as first in fontFamily 2023-11-15 19:58:55 -06:00
c86bfcff4d CE-740 Export button style 2023-11-15 19:58:35 -06:00
c8fe46c5bf CE-740 Remove margin/padding (in the card now) 2023-11-15 19:58:08 -06:00
8de2f2ce33 CE-740 Revert grid-item padding hack (borke a lot of things) 2023-11-15 19:58:01 -06:00
036c2253b1 CE-740 Remove margin/padding (in the card now) 2023-11-15 19:57:49 -06:00
6c6c1cfe3d CE-740 Border, padding adjustments 2023-11-15 19:57:39 -06:00
754010df3d CE-740 Revert grid-item padding hack (borke a lot of things) 2023-11-15 19:57:25 -06:00
9d7315e773 Add %'s to tooltips 2023-11-15 19:57:07 -06:00
12f13983ea CE-740 Remove margin/padding (in the card now) 2023-11-15 19:56:47 -06:00
ab0fb977fb CE-740 font weight 2023-11-15 19:56:26 -06:00
7cb3f2284d CE-740 line colors 2023-11-15 19:56:10 -06:00
6bdd8ed935 CE-740 font sizes, colors, and padding 2023-11-15 19:55:47 -06:00
4f0469a04c CE-740 line colors 2023-11-15 19:55:19 -06:00
f1c3b93049 CE-740 Widget and tab spacing adjustments; pass iconPath down into HeaderIcon 2023-11-15 19:54:59 -06:00
1c1cfc6d75 CE-740 Add blueGray and grayLines colors 2023-11-15 19:54:18 -06:00
87d0c7d478 CE-740 chart subhead style edits 2023-11-15 13:39:16 -06:00
fbcee2b819 CE-740 make all h3 same size; update styles on recently-viewed 2023-11-15 13:15:53 -06:00
c1065099e5 CE-740 Style on page header; dark.main color; sticky app header bar margin & width 2023-11-15 12:11:35 -06:00
adcfa86f73 CE-740 style on / 2023-11-15 11:37:25 -06:00
ad306728c2 CE-740 Update icon size 2023-11-15 11:34:18 -06:00
5969f1a6ba CE-740 Update breadcrumb size, weight, and color 2023-11-15 11:34:18 -06:00
6c3bfa776a CE-740 Adjusting grid padding to 20px; overall body padding to 20px, and margin-let to account for resized sidenav 2023-11-15 11:34:18 -06:00
924e657531 CE-740 Changed sidebar width from 275 to 245 2023-11-15 11:34:17 -06:00
b52d0977cb Make date sticky 2023-11-10 10:48:36 -06:00
ea728d3cf0 fix to selenium test by updating pom dependency 2023-11-08 17:09:22 -06:00
e5430101fa updates to put blob data back into data that is posted to backend on record edits 2023-11-08 08:57:42 -06:00
0879cb4f80 CE-604 Let data from backend drive backgroundColors if it wants 2023-11-03 19:23:53 -05:00
f1b0618e9d CE-604 Disable tooltips for now (our only active use case doesn't want them) 2023-11-03 19:23:38 -05:00
95f1fa83bb CE-604 Change to use xxl instead of lg for using default grid sizing (more likely to go 12 now) 2023-11-03 09:53:53 -05:00
4b0e12ba47 CE-604 Adjust a height in light of other redesign updates 2023-11-03 09:12:28 -05:00
6cfb1e04ed CE-604 Adjust a padding in light of tabs 2023-11-03 09:11:45 -05:00
0d763cbfc8 CE-604 Adjust legend dot size, padding, and font 2023-11-02 20:14:56 -05:00
279840e77a CE-604 Increase height of pie & stacked bar chart from 250 to 300 - surprisingly seems better than just a 50px improvement 2023-11-02 20:04:51 -05:00
51b2f5bb5a CE-604 Better wrapping (flex-wrap!) 2023-11-02 19:56:55 -05:00
02fe351084 make sure rows exist in table data before iterating over them 2023-11-02 14:40:29 -05:00
25fa2e82ea Merged main into feature/CE-604-complete-shipment-sla-updates-and-local-tnt-rules 2023-10-27 16:05:08 -05:00
24b4674208 Merged feature/remove-old-auth-header into feature/CE-604-complete-shipment-sla-updates-and-local-tnt-rules 2023-10-27 16:04:50 -05:00
04630fd154 CE-604 make grid always fit on screen, so h-scrollbar & column headers are always present (by turning off autoHeight and adding a set height 2023-10-27 14:28:57 -05:00
1503e2a1d5 CE-604 Elevation on status modal 2023-10-27 14:25:09 -05:00
3d7502531d CE-604 Remove redundant numeric value in pie labels 2023-10-27 14:24:59 -05:00
d0a7db28fe CE-604 make subgrows gray; make expansion arrow icon not make rows taller 2023-10-27 14:24:37 -05:00
95244a8aba CE-604 Make tab parents remember current selection; fix changing data passing through multiple levels of parents 2023-10-27 14:24:14 -05:00
45f247785c fixed bug where datetimes were cleared when posting, resulting in empty form fields for next submit 2023-10-26 12:23:30 -05:00
9e6d5c10fb CE-604 Updates to DataTable for fixed-sticky footer and expandable rows; Misc style updates going along with e.g., card border change 2023-10-24 11:36:10 -05:00
4e0b13ad02 upped revision to 0.20.0-SNAPSHOT 2023-10-23 11:05:41 -05:00
a26f939859 Remove Authorization: <accessToken> from all posts 2023-10-20 19:34:51 -05:00
7f57a11e00 CE-604 Update revision to 0.20.0-SNAPSHOT 2023-10-20 10:41:03 -05:00
83da3a3a0a CE-604 Update qqq-frontend-core to 1.0.83 2023-10-20 10:40:39 -05:00
b59ed8c8c1 CE-604 Fix some grammar error (but leave in all the extra commas, Tim) 2023-10-20 10:40:16 -05:00
7101420124 CE-604 Update tooltip styles - wider, dark on light, left-align, box-shadow. 2023-10-20 10:39:57 -05:00
b903e6bef9 CE-604 Adding chartSubheaderData; updating styles 2023-10-20 10:32:00 -05:00
970c9f262c CE-604 Add support for layoutType TABS 2023-10-20 10:30:51 -05:00
9313988f9b CE-604 Add HeaderIcon component; minor style updates for supporting tabs 2023-10-20 10:30:31 -05:00
123d1742e7 CE-604 Update global tab-styles 2023-10-20 10:29:46 -05:00
47fca52437 CE-604 Add topRightInsideCardIcon as a right-component and chartSubheaderData in StackedBarChart and PieChart; Add support for tabs; 2023-10-20 10:27:33 -05:00
44b92690ab CE-604 Initial checkin 2023-10-20 10:21:10 -05:00
64fe2305ad CE-604 Add error box below BooleanFieldSwitch 2023-10-20 10:15:09 -05:00
91d38a1d15 CE-604 Show null values as a switch that isn't leaning towards yes or no; add bgcolor gray when field is disabled 2023-10-20 10:14:37 -05:00
e3c511ef6d Merge pull request #35 from Kingsrook/bugfix/widget-exports
Bugfix/widget exports
2023-10-18 11:28:33 -05:00
60a8baff35 Style updates per paul-designs (turn off general card elevation in favor of card borders; remove margin around left-nav;) 2023-10-18 10:42:04 -05:00
81b46408b4 Turning hot-dog menu button (to show menu when in mobile) back on;
Hiding recently viewed on smallest screens
Updating style on recently viewed to match new paul design
2023-10-18 10:40:41 -05:00
e993fcb949 Add better support (hopefully that works in CI) for downloads; update this test to use that. 2023-10-18 08:53:41 -05:00
e144cf3ec7 Added scriptViewer widget back in metaData to fix tests that needed it; turned on showExportButton in sample table widget; 2023-10-17 20:36:23 -05:00
6bd6b0370b Initial checkin 2023-10-17 19:26:24 -05:00
448560c427 Add option to autoHighlight elements. Add getLatestChromeDownloadedFileInfo; maybe fix collisions when writing screenshots 2023-10-17 19:25:20 -05:00
791b50b893 Redo export buttons as JSX Elements that get passed into Widget.tsx, rather than "Component" objects. something to do with when things are getting bound, was making the export buttons never have data. This was done with labelAdditionalElementsLeft, similar to labelAdditionalComponentsLeft. In theory, maybe, this is better, and we should remove all the additionalComponents left & right... 2023-10-17 19:23:02 -05:00
b968705a01 Update class header comment 2023-10-17 19:16:04 -05:00
0949ee9f78 session cookie fix - to say we need it if it isn't set.
also, just let backend request set it (it already was sending header).  also, log more.  also, remove unused attribute `pathToLabelMap` from `SideNav` (was issuing warnings)
2023-10-13 08:37:19 -05:00
74 changed files with 2689 additions and 1294 deletions

View File

@ -2,7 +2,7 @@ version: 2.1
orbs:
node: circleci/node@5.1.0
browser-tools: circleci/browser-tools@1.4.5
browser-tools: circleci/browser-tools@1.4.6
executors:
java17:

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.82",
"@kingsrook/qqq-frontend-core": "1.0.85",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -42,6 +42,7 @@
"react-dom": "18.0.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.19.0-SNAPSHOT</revision>
<revision>0.20.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -77,13 +77,13 @@
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.10.0</version>
<version>4.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.4.1</version>
<version>5.6.2</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@ -36,7 +36,7 @@ import {LicenseInfo} from "@mui/x-license-pro";
import jwt_decode from "jwt-decode";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation,} from "react-router-dom";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
import CommandMenu from "CommandMenu";
import QContext from "QContext";
@ -62,7 +62,7 @@ export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App()
{
const [, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0();
const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
@ -73,10 +73,25 @@ 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])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
@ -91,7 +106,7 @@ export default function App()
const oldExp = oldJSON["exp"];
if(oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired.");
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
@ -107,7 +122,7 @@ export default function App()
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if(different)
{
console.log("Latest access token from auth0 has changed vs localStorage.");
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
@ -146,22 +161,22 @@ export default function App()
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const newSessionUuid = await qController.manageSession(accessToken, null);
setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
}
/*
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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");
*/
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user);
console.log("Token load complete.");
@ -182,8 +197,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;
@ -211,6 +226,7 @@ export default function App()
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]);
@ -502,7 +518,7 @@ export default function App()
name: loggedInUser?.name ?? "Anonymous",
key: "username",
noCollapse: true,
icon: <Avatar src={profilePicture} alt="{user?.name}" />,
icon: <Avatar src={profilePicture} alt="{loggedInUser?.name}" />,
};
setProfileRoutes(profileRoutes);
@ -644,6 +660,8 @@ export default function App()
const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
return (
appRoutes && (
@ -654,6 +672,7 @@ export default function App()
tableProcesses: tableProcesses,
dotMenuOpen: dotMenuOpen,
keyboardHelpOpen: keyboardHelpOpen,
helpHelpActive: helpHelpActive,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
@ -673,7 +692,6 @@ export default function App()
appName={branding.appName}
branding={branding}
routes={sideNavRoutes}
pathToLabelMap={pathToLabelMap}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
/>
@ -686,4 +704,4 @@ export default function App()
</QContext.Provider>
)
);
}
}

View File

@ -51,6 +51,7 @@ interface QContext
///////////////////////////////////
pathToLabelMap?: {[path: string]: string};
branding?: QBrandingMetaData;
helpHelpActive?: boolean;
}
const defaultState = {
@ -59,6 +60,7 @@ const defaultState = {
dotMenuOpen: false,
keyboardHelpOpen: false,
pathToLabelMap: {},
helpHelpActive: false,
};
const QContext = createContext<QContext>(defaultState);

View File

@ -0,0 +1,31 @@
/*
* 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

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

View File

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

View File

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

View File

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

View File

@ -1,68 +1,75 @@
/**
=========================================================
* 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 { borderRadius } = borders;
const { tabsBoxShadow } = boxShadows;
const {grey, white} = colors;
// types
type Types = any;
const tabs: Types = {
styleOverrides: {
root: {
position: "relative",
backgroundColor: grey[100],
borderRadius: borderRadius.xl,
minHeight: "unset",
padding: pxToRem(4),
},
flexContainer: {
height: "100%",
position: "relative",
zIndex: 10,
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
},
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
styleOverrides: {
root: {
position: "relative",
borderRadius: 0,
borderBottom: "1px solid",
borderBottomColor: grey[400],
minHeight: "unset",
padding: "0",
margin: "0",
"& button": {
fontWeight: 500
}
},
},
indicator: {
height: "100%",
borderRadius: borderRadius.lg,
backgroundColor: white.main,
boxShadow: tabsBoxShadow.indicator,
transition: "all 500ms ease",
},
},
scroller: {
marginLeft: "0.5rem"
},
flexContainer: {
height: "100%",
position: "relative",
width: "fit-content",
zIndex: 10,
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
},
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
},
},
indicator: {
height: "100%",
borderRadius: 0,
backgroundColor: white.main,
borderBottom: "2px solid",
borderBottomColor: colors.info.main,
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,48 +21,50 @@ 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: pxToRem(4),
borderRadius: borderRadius.lg,
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: "0.75rem 0.5rem 0.5rem",
margin: "0 0.5rem",
borderRadius: 0,
border: 0,
color: `${dark.main} !important`,
opacity: "1 !important",
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
},
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
labelIcon: {
paddingTop: pxToRem(4),
},
},
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 } = colors;
const { black, light, white, dark } = colors;
const { size, fontWeightRegular } = typography;
const { borderRadius } = borders;
@ -39,19 +39,20 @@ const tooltip: Types = {
styleOverrides: {
tooltip: {
maxWidth: pxToRem(200),
backgroundColor: black.main,
color: light.main,
maxWidth: pxToRem(300),
backgroundColor: white.main,
color: dark.main,
fontSize: size.sm,
fontWeight: fontWeightRegular,
textAlign: "center",
textAlign: "left",
borderRadius: borderRadius.md,
opacity: 0.7,
padding: `${pxToRem(5)} ${pxToRem(8)} ${pxToRem(4)}`,
padding: "1rem",
boxShadow: "0px 0px 12px rgba(128, 128, 128, 0.40)"
},
arrow: {
color: black.main,
color: white.main,
},
},
};

View File

@ -402,14 +402,16 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
return (
<Box key={audit0.values.get("id")} className="auditGroupBlock">
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
<Box position="sticky" top="0" zIndex={3}>
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14} position={"relative"} top={"-1px"} pb={"6px"} sx={{backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 80%, rgba(255,255,255,0))"}}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
</Box>
{

View File

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

View File

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

View File

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

View File

@ -89,6 +89,7 @@ class DynamicFormUtils
label += field.isRequired ? " *" : "";
return ({
fieldMetaData: field,
name: field.name,
label: label,
isRequired: field.isRequired,

View File

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

View File

@ -37,10 +37,12 @@ import React, {useContext, useEffect, useReducer, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom";
import * as Yup from "yup";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
@ -79,6 +81,7 @@ function EntityForm(props: Props): JSX.Element
const [validations, setValidations] = useState({});
const [initialValues, setInitialValues] = useState({} as { [key: string]: any });
const [formFields, setFormFields] = useState(null as Map<string, any>);
const [t1section, setT1Section] = useState(null as QTableSection);
const [t1sectionName, setT1SectionName] = useState(null as string);
const [nonT1Sections, setNonT1Sections] = useState([] as QTableSection[]);
@ -151,7 +154,9 @@ function EntityForm(props: Props): JSX.Element
{
return <div>Loading...</div>;
}
return <QDynamicForm formData={formData} record={record} />;
const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
return <QDynamicForm formData={formData} record={record} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableName};`} />;
}
if (!asyncLoadInited)
@ -330,6 +335,7 @@ function EntityForm(props: Props): JSX.Element
/////////////////////////////////////
const dynamicFormFieldsBySection = new Map<string, any>();
let t1sectionName;
let t1section;
const nonT1Sections: QTableSection[] = [];
for (let i = 0; i < tableSections.length; i++)
{
@ -382,6 +388,7 @@ function EntityForm(props: Props): JSX.Element
if (section.tier === "T1")
{
t1sectionName = section.name;
t1section = section;
}
else
{
@ -389,6 +396,7 @@ function EntityForm(props: Props): JSX.Element
}
}
setT1SectionName(t1sectionName);
setT1Section(t1section);
setNonT1Sections(nonT1Sections);
setFormFields(dynamicFormFieldsBySection);
setValidations(Yup.object().shape(formValidations));
@ -426,6 +434,11 @@ 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);
@ -438,17 +451,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 && values[fieldName])
if(fieldMetaData.type === QFieldType.DATE_TIME && valuesToPost[fieldName])
{
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${values[fieldName]}]`)
if (initialValues[fieldName] == values[fieldName])
console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${valuesToPost[fieldName]}]`)
if (initialValues[fieldName] == valuesToPost[fieldName])
{
console.log(" - Is the same, so, deleting from the post");
delete (values[fieldName]);
delete (valuesToPost[fieldName]);
}
else
{
values[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(values[fieldName]);
valuesToPost[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(valuesToPost[fieldName]);
}
}
@ -461,10 +474,14 @@ function EntityForm(props: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(fieldMetaData.type === QFieldType.BLOB)
{
if(typeof values[fieldName] === "string")
if(typeof valuesToPost[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(values[fieldName]);
delete(valuesToPost[fieldName]);
}
else
{
valuesToPost[fieldName] = values[fieldName];
}
}
}
@ -473,7 +490,7 @@ function EntityForm(props: Props): JSX.Element
{
// todo - audit that it's a dupe
await qController
.update(tableName, props.id, values)
.update(tableName, props.id, valuesToPost)
.then((record) =>
{
if (props.isModal)
@ -506,7 +523,7 @@ function EntityForm(props: Props): JSX.Element
else
{
await qController
.create(tableName, values)
.create(tableName, valuesToPost)
.then((record) =>
{
if (props.isModal)
@ -543,6 +560,19 @@ function EntityForm(props: Props): JSX.Element
const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`;
let body;
const getSectionHelp = (section: QTableSection) =>
{
const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
const formattedHelpContent = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableMetaData.name};section:${section.name}`} />;
return formattedHelpContent && (
<Box px={"1.5rem"} fontSize={"0.875rem"}>
{formattedHelpContent}
</Box>
)
}
if (notAllowedError)
{
body = (
@ -564,23 +594,26 @@ function EntityForm(props: Props): JSX.Element
}
else
{
const cardElevation = props.isModal ? 3 : 1;
const cardElevation = props.isModal ? 3 : 0;
body = (
<Box mb={3}>
<Grid container spacing={3}>
<Grid item xs={12}>
{alertContent ? (
<Box mb={3}>
<Alert severity="error" onClose={() => setAlertContent(null)}>{alertContent}</Alert>
</Box>
) : ("")}
{warningContent ? (
<Box mb={3}>
<Alert severity="warning" onClose={() => setWarningContent(null)}>{warningContent}</Alert>
</Box>
) : ("")}
{
(alertContent || warningContent) &&
<Grid container spacing={3}>
<Grid item xs={12}>
{alertContent ? (
<Box mb={3}>
<Alert severity="error" onClose={() => setAlertContent(null)}>{alertContent}</Alert>
</Box>
) : ("")}
{warningContent ? (
<Box mb={3}>
<Alert severity="warning" onClose={() => setWarningContent(null)}>{warningContent}</Alert>
</Box>
) : ("")}
</Grid>
</Grid>
</Grid>
}
<Grid container spacing={3}>
{
!props.isModal &&
@ -618,10 +651,11 @@ function EntityForm(props: Props): JSX.Element
<MDTypography variant="h5">{formTitle}</MDTypography>
</Box>
</Box>
{t1section && getSectionHelp(t1section)}
{
t1sectionName && formFields ? (
<Box pb={1} px={3}>
<Box p={3} width="100%">
<Box px={3}>
<Box pb={"0.25rem"} width="100%">
{getFormSection(values, touched, formFields.get(t1sectionName), errors)}
</Box>
</Box>
@ -635,8 +669,9 @@ function EntityForm(props: Props): JSX.Element
<MDTypography variant="h6" p={3} pb={1}>
{section.label}
</MDTypography>
{getSectionHelp(section)}
<Box pb={1} px={3}>
<Box p={3} width="100%">
<Box pb={"0.75rem"} width="100%">
{getFormSection(values, touched, formFields.get(section.name), errors)}
</Box>
</Box>

View File

@ -25,6 +25,7 @@ import Icon from "@mui/material/Icon";
import {ReactNode, useContext} from "react";
import {Link} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
interface Props
@ -112,43 +113,35 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
<Box mr={{xs: 0, xl: 8}}>
<MuiBreadcrumbs
sx={{
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main,
"& li": {
lineHeight: "unset!important"
},
"& a": {
color: colors.gray.main
},
"& .MuiBreadcrumbs-separator": {
color: ({palette: {white, grey}}) => (light ? white.main : grey[600]),
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main
},
}}
>
<Link to="/">
<MDTypography
component="span"
variant="body2"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
<Icon>{icon}</Icon>
</MDTypography>
<Icon sx={{fontSize: "1.25rem!important", position: "relative", top: "0.25rem"}}>{icon}</Icon>
</Link>
{fullRoutes.map((fullRoute: string) => (
<Link to={fullRoute} key={fullRoute}>
<MDTypography
component="span"
variant="button"
fontWeight="regular"
textTransform="capitalize"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
{fullPathToLabel(fullRoute, fullRoute.replace(/.*\//, ""))}
</MDTypography>
{fullPathToLabel(fullRoute, fullRoute.replace(/.*\//, ""))}
</Link>
))}
</MuiBreadcrumbs>
<MDTypography
pt={1}
fontWeight="bold"
textTransform="capitalize"
variant="h5"
variant="h3"
color={light ? "white" : "dark"}
noWrap
>

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Popper} from "@mui/material";
import {Popper, InputAdornment} 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, navbarIconButton, navbarRow,} from "qqq/components/horseshoe/Styles";
import {setTransparentNavbar, useMaterialUIController,} from "qqq/context";
import {navbar, navbarContainer, navbarRow, navbarMobileMenu, recentlyViewedMenu,} from "qqq/components/horseshoe/Styles";
import {setTransparentNavbar, useMaterialUIController, setMiniSidenav} 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 {transparentNavbar, fixedNavbar, darkMode,} = controller;
const {miniSidenav, transparentNavbar, fixedNavbar, darkMode,} = controller;
const [openMenu, setOpenMenu] = useState<any>(false);
const [history, setHistory] = useState<any>([] as HistoryEntry[]);
const [autocompleteValue, setAutocompleteValue] = useState<any>(null);
@ -105,6 +105,8 @@ 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);
@ -157,12 +159,20 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
options={history}
autoHighlight
blurOnSelect
style={{width: "200px"}}
style={{width: "16rem"}}
onOpen={handleHistoryOnOpen}
onChange={handleAutocompleteOnChange}
PopperComponent={CustomPopper}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" />}
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>
)
}} />}
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}>
@ -175,22 +185,6 @@ 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},
@ -240,26 +234,22 @@ 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={1}>
<Box pr={0} mr={-2} mt={-4}>
{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

@ -20,6 +20,7 @@
*/
import {Theme} from "@mui/material/styles";
import colors from "qqq/assets/theme/base/colors";
function navbar(theme: Theme | any, ownerState: any)
{
@ -110,11 +111,10 @@ const navbarContainer = ({breakpoints}: Theme): any => ({
const navbarRow = ({breakpoints}: Theme, {isMini}: any) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
[breakpoints.up("md")]: {
justifyContent: isMini ? "space-between" : "stretch",
justifyContent: "stretch",
width: isMini ? "100%" : "max-content",
},
@ -146,12 +146,37 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({
display: "none !important",
cursor: "pointer",
[breakpoints.up("xl")]: {
[breakpoints.down("sm")]: {
display: "inline-block !important",
},
});
const recentlyViewedMenu = ({breakpoints}: Theme) => ({
"& .MuiInputLabel-root": {
color: colors.gray.main,
fontWeight: "500",
fontSize: "1rem"
},
"& .MuiInputAdornment-root": {
marginTop: "0.5rem",
color: colors.gray.main,
fontSize: "1rem"
},
"& .MuiOutlinedInput-root": {
borderRadius: "0",
padding: "0"
},
"& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
border: "0"
},
display: "block",
[breakpoints.down("md")]: {
display: "none !important",
},
});
const navbarMobileMenu = ({breakpoints}: Theme) => ({
left: "-0.75rem",
display: "inline-block",
lineHeight: 0,
@ -167,4 +192,5 @@ 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 = 250;
const sidebarWidth = 245;
const {transparent, gradients, white, background} = palette;
const {xxl} = boxShadows;
const {pxToRem, linearGradient} = functions;
@ -94,6 +94,9 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
"& .MuiDrawer-paper": {
boxShadow: xxl,
border: "none",
margin: "0",
borderRadius: "0",
height: "100%",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,11 +28,12 @@ interface TabPanelProps
children?: React.ReactNode;
index: number;
value: number;
style?: any;
}
export default function TabPanel(props: TabPanelProps)
{
const {children, value, index, ...other} = props;
const {children, value, index, style, ...other} = props;
return (
<div
@ -40,6 +41,7 @@ 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 records input records will immediately be processed.
If you choose this option, the 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

@ -215,6 +215,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard"
/>
</Box>;
case ValueMode.PVS_MULTI:
@ -242,6 +243,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues={initialValues}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
/>
</Box>;
}

View File

@ -22,11 +22,13 @@ 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";
@ -44,7 +46,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, {WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
import Widget, {HeaderIcon, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, LabelComponent} from "qqq/components/widgets/Widget";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
import TableWidget from "./tables/TableWidget";
@ -58,9 +60,10 @@ interface Props
tableName?: string;
entityPrimaryKey?: string;
omitWrappingGridContainer: boolean;
areChildren?: boolean
childUrlParams?: string
parentWidgetMetaData?: QWidgetMetaData
areChildren?: boolean;
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
}
DashboardWidgets.defaultProps = {
@ -70,12 +73,12 @@ DashboardWidgets.defaultProps = {
omitWrappingGridContainer: false,
areChildren: false,
childUrlParams: "",
parentWidgetMetaData: null
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData}: Props): JSX.Element
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
{
const location = useLocation();
const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetCounter, setWidgetCounter] = useState(0);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -84,6 +87,24 @@ 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([]);
@ -102,15 +123,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;
}
@ -123,7 +144,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const reloadWidget = async (index: number, data: string) =>
{
(async() =>
(async () =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -140,7 +161,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData[index]["errorLoading"] = false;
}
}
catch(e)
catch (e)
{
console.error(e);
if (widgetData[index])
@ -151,7 +172,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
forceUpdate();
})();
}
};
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
{
@ -178,36 +199,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]);
}
@ -227,6 +248,16 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{
const labelAdditionalComponentsRight: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topRightInsideCardIcon = widgetMetaData.icons.get("topRightInsideCard");
if (topRightInsideCardIcon)
{
labelAdditionalComponentsRight.push(new HeaderIcon(topRightInsideCardIcon.name, topRightInsideCardIcon.path, topRightInsideCardIcon.color));
}
}
return (
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
{
@ -238,7 +269,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetIndex={i}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={reloadWidget}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
storeDropdownSelections={widgetMetaData.storeDropdownSelections}
/>
)
@ -270,8 +301,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
>
<StackedBarChart data={widgetData[i]?.chartData}/>
<StackedBarChart data={widgetData[i]?.chartData} chartSubheaderData={widgetData[i]?.chartSubheaderData} />
</Widget>
)
}
@ -311,7 +343,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
reloadWidgetCallback={(data) => reloadWidget(i, data)}
widgetData={widgetData[i]}
>
<Box px={3} pt={0} pb={2}>
<Box>
<MDTypography component="div" variant="button" color="text" fontWeight="light">
{
widgetData && widgetData[i] && widgetData[i].html ? (
@ -381,10 +413,12 @@ 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>
@ -436,11 +470,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)}
/>
)
}
{
@ -461,32 +495,76 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
</Box>
);
};
if(wrapWidgetsInTabPanels)
{
omitWrappingGridContainer = true;
}
const body: JSX.Element =
(
<>
{
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>
))
widgetMetaDataList.map((widgetMetaData, i) =>
{
let renderedWidget = widgetMetaData ? renderWidget(widgetMetaData, i) : (<></>);
if (!omitWrappingGridContainer)
{
// @ts-ignore
renderedWidget = (<Grid id={widgetMetaData.name} item xxl={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget}
</Grid>);
}
if (wrapWidgetsInTabPanels)
{
renderedWidget = (<TabPanel index={i} value={selectedTab} style={{
padding: 0,
margin: "-1rem",
marginBottom: "-3.5rem",
width: "calc(100% + 2rem)"
}}>
{renderedWidget}
</TabPanel>);
}
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>)
})
}
</>
);
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs
sx={{m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
"& .MuiTabs-scroller": {
ml: 0
}
}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
{widgetMetaDataList.map((widgetMetaData, i) => (
<Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))}
</Tabs>
: <></>
return (
widgetCount > 0 ? (
omitWrappingGridContainer ? body :
(
<Grid container spacing={3} pb={4}>
{body}
</Grid>
)
<>
{tabs}
{
omitWrappingGridContainer ? body : (
<Grid container spacing={2.5}>
{body}
</Grid>
)
}
</>
) : null
);
}

View File

@ -43,6 +43,7 @@ export interface ParentWidgetData
dropdownNeedsSelectedText?: string;
storeDropdownSelections?: boolean;
icon?: string;
layoutType: string;
}
@ -55,7 +56,7 @@ interface Props
widgetMetaData?: QWidgetMetaData;
widgetIndex: number;
data: ParentWidgetData;
reloadWidgetCallback?: (widgetIndex: number, params: string) => void;
reloadWidgetCallback?: (params: string) => void;
entityPrimaryKey?: string;
tableName?: string;
storeDropdownSelections?: boolean;
@ -91,18 +92,29 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
}
}, [qInstance, data, childUrlParams]);
useEffect(() =>
{
setChildUrlParams(urlParams)
}, [urlParams]);
const parentReloadWidgetCallback = (data: string) =>
{
setChildUrlParams(data);
reloadWidgetCallback(widgetIndex, data);
reloadWidgetCallback(data);
}
///////////////////////////////////////////////////////////////////////////////////////////
// if this parent widget is in card form, and its children are too, then we need some px //
///////////////////////////////////////////////////////////////////////////////////////////
const px = (widgetMetaData && widgetMetaData.isCard && widgets && widgets[0] && widgets[0].isCard) ? 3 : 0;
const parentIsCard = widgetMetaData && widgetMetaData.isCard;
const childrenAreCards = widgetMetaData && widgets && widgets[0] && widgets[0].isCard;
const px = (parentIsCard && childrenAreCards) ? 3 : 0;
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if this is a parent, which is not a card, then we need to omit the padding, i think, on the Widget that ultimately gets rendered //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const omitPadding = !parentIsCard;
// @ts-ignore
return (
qInstance && data ? (
<Widget
@ -110,9 +122,10 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
widgetData={data}
storeDropdownSelections={storeDropdownSelections}
reloadWidgetCallback={parentReloadWidgetCallback}
omitPadding={omitPadding}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData}/>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType == "TABS"}/>
</Box>
</Widget>
) : null

View File

@ -25,14 +25,13 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import LinearProgress from "@mui/material/LinearProgress";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import React, {useEffect, useState} from "react";
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
import {NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/assets/theme/base/colors";
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
export interface WidgetData
{
@ -43,9 +42,11 @@ export interface WidgetData
id: string,
label: string
}[][];
dropdownDefaultValueList?: string[];
dropdownNeedsSelectedText?: string;
hasPermission?: boolean;
errorLoading?: boolean;
[other: string]: any;
}
@ -53,7 +54,9 @@ export interface WidgetData
interface Props
{
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
labelBoxAdditionalSx?: any;
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
children: JSX.Element;
@ -62,6 +65,7 @@ interface Props
isChild?: boolean;
footerHTML?: string;
storeDropdownSelections?: boolean;
omitPadding: boolean;
}
Widget.defaultProps = {
@ -70,7 +74,10 @@ Widget.defaultProps = {
widgetMetaData: {},
widgetData: {},
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
labelBoxAdditionalSx: {},
omitPadding: false,
};
@ -88,34 +95,56 @@ export class LabelComponent
{
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (<div>Unsupported component type</div>)
}
return (<div>Unsupported component type</div>);
};
}
/*******************************************************************************
**
*******************************************************************************/
export class HeaderLink extends LabelComponent
export class HeaderIcon extends LabelComponent
{
label: string;
to: string
iconName: string;
iconPath: string;
color: string;
coloredBG: boolean;
constructor(label: string, to: string)
iconColor: string;
bgColor: string;
constructor(iconName: string, iconPath: string, color: string, coloredBG: boolean = true)
{
super();
this.label = label;
this.to = to;
this.iconName = iconName;
this.iconPath = iconPath;
this.color = color;
this.coloredBG = coloredBG;
this.iconColor = this.coloredBG ? "#FFFFFF" : this.color;
this.bgColor = this.coloredBG ? this.color : "none";
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
{this.to ? <Link to={this.to}>{this.label}</Link> : null}
</Typography>
);
}
const styles = {
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
backgroundColor: this.bgColor,
borderRadius: "0.25rem"
};
if (this.iconPath)
{
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>);
}
else
{
return (<Icon sx={{padding: "0.25rem", ...styles}} fontSize="small">{this.iconName}</Icon>);
}
};
}
@ -141,45 +170,17 @@ 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 =>
{
return (
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="0.25rem">
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="-0.5rem">
<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>
);
}
};
}
@ -189,45 +190,115 @@ export class ExportDataButton extends LabelComponent
export class Dropdown extends LabelComponent
{
label: string;
dropdownMetaData: any;
options: DropdownOption[];
dropdownDefaultValue?: string;
dropdownName: string;
onChangeCallback: any;
constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any)
constructor(label: string, dropdownMetaData: any, options: DropdownOption[], dropdownDefaultValue: string, dropdownName: string, onChangeCallback: any)
{
super();
this.label = label;
this.dropdownMetaData = dropdownMetaData;
this.options = options;
this.dropdownDefaultValue = dropdownDefaultValue;
this.dropdownName = dropdownName;
this.onChangeCallback = onChangeCallback;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const label = `Select ${this.label}`;
let defaultValue = null;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`;
if (args.widgetProps.storeDropdownSelections)
{
///////////////////////////////////////////////////////////////////////////////////////
// see if an existing value is stored in local storage, and if so set it in dropdown //
///////////////////////////////////////////////////////////////////////////////////////
defaultValue = JSON.parse(localStorage.getItem(localStorageKey));
args.dropdownData[args.componentIndex] = defaultValue?.id;
////////////////////////////////////////////////////////////////////////////////////////////
// see if an existing value is stored in local storage, and if so set it in dropdown //
// originally we used the full object from localStorage - but - in case the label //
// changed since it was stored, we'll instead just find the option by id (or in case that //
// option isn't available anymore, then we'll select nothing instead of a missing value //
////////////////////////////////////////////////////////////////////////////////////////////
try
{
const localStorageOption = JSON.parse(localStorage.getItem(localStorageKey));
if(localStorageOption)
{
const id = localStorageOption.id;
for (let i = 0; i < this.options.length; i++)
{
if (this.options[i].id == id)
{
defaultValue = this.options[i]
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
}
}
}
catch(e)
{
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e)
}
}
/////////////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a value selected, but there is a default from the backend, then use it. //
/////////////////////////////////////////////////////////////////////////////////////////////
if (defaultValue == null && this.dropdownDefaultValue != null)
{
for (let i = 0; i < this.options.length; i++)
{
if(this.options[i].id == this.dropdownDefaultValue)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
if (args.widgetProps.storeDropdownSelections)
{
localStorage.setItem(localStorageKey, JSON.stringify(defaultValue));
}
this.onChangeCallback(label, defaultValue);
break;
}
}
}
/////////////////////////////////////////////////////////////////////////////
// if there's a 'label for null value' (and no default from the backend), //
// then add that as an option (and select it if nothing else was selected) //
/////////////////////////////////////////////////////////////////////////////
let options = this.options;
if (this.dropdownMetaData.labelForNullValue && !this.dropdownDefaultValue)
{
const nullOption = {id: null as string, label: this.dropdownMetaData.labelForNullValue};
options = [nullOption, ...this.options];
if (!defaultValue)
{
defaultValue = nullOption;
}
}
return (
<Box my={2} sx={{float: "right"}}>
<DropdownMenu
<Box mb={2} sx={{float: "right"}}>
<WidgetDropdownMenu
name={this.dropdownName}
defaultValue={defaultValue}
sx={{width: 200, marginLeft: "15px"}}
label={`Select ${this.label}`}
dropdownOptions={this.options}
sx={{marginLeft: "1rem"}}
label={label}
startIcon={this.dropdownMetaData.startIconName}
allowBackAndForth={this.dropdownMetaData.allowBackAndForth}
backAndForthInverted={this.dropdownMetaData.backAndForthInverted}
disableClearable={this.dropdownMetaData.disableClearable}
dropdownOptions={options}
onChangeCallback={this.onChangeCallback}
width={this.dropdownMetaData.width ?? 225}
/>
</Box>
);
}
};
}
@ -247,11 +318,11 @@ export class ReloadControl extends LabelComponent
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.175rem">
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon sx={{color: colors.gray.main, fontSize: 1.125}}>refresh</Icon></Button></Tooltip>
</Typography>
);
}
};
}
@ -333,7 +404,12 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
{
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange));
let defaultValue = null;
if(props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
{
defaultValue = props.widgetData.dropdownDefaultValueList[index];
}
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
});
setLabelComponentsRight(updatedStateLabelComponentsRight);
}
@ -372,7 +448,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;
@ -394,7 +470,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
}
reloadWidget(dropdownData)
reloadWidget(dropdownData);
}
}
@ -422,7 +498,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
}
}
};
const toggleFullScreenWidget = () =>
{
@ -434,14 +510,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 //
@ -450,36 +526,50 @@ 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={{cursor: "default", pl: "auto", pt: props.widgetMetaData.type == "parentWidget" ? "1rem" : "auto", fontWeight: 600}} variant="h6" 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%"}} height={"3.5rem"}>
<Box pt={2} pb={1}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"}>
<Box>
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
<Box ml={1} mr={2} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">
{props.widgetMetaData.icon}
@ -487,40 +577,24 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
) :
(
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
<Box ml={3} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">lock</Icon>
</Box>
)
}
{
//////////////////////////////////////////////////////////////////////////////////////////
// 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 && labelToUse && (labelElement)
}
{
hasPermission && (
@ -530,6 +604,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
})
)
}
{props.labelAdditionalElementsLeft}
</Box>
<Box>
{
@ -544,7 +619,12 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
}
{
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0"}} /> : <Box height="0.375rem" />)
///////////////////////////////////////////////////////////////////
// turning this off... for now. maybe make a property in future //
///////////////////////////////////////////////////////////////////
/*
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0", mx:-2}} /> : <Box height="0.375rem" />)
*/
}
{
errorLoading ? (
@ -554,7 +634,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
) : (
hasPermission && props.widgetData?.dropdownNeedsSelectedText ? (
<Box pb={3} pr={3} sx={{width: "100%", textAlign: "right"}}>
<Box pb={3} sx={{width: "100%", textAlign: "right"}}>
<Typography variant="body2">
{props.widgetData?.dropdownNeedsSelectedText}
</Typography>
@ -575,11 +655,12 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
</Box>;
const padding = props.omitPadding ? "auto" : "24px 16px";
return props.widgetMetaData?.isCard
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className={fullScreenWidgetClassName}>
{widgetContent}
</Card>
: widgetContent;
: <span style={{width: "100%", padding: padding}}>{widgetContent}</span>;
}
export default Widget;

View File

@ -28,6 +28,7 @@ import {Bar} from "react-chartjs-2";
import {useNavigate} from "react-router-dom";
import colors from "qqq/assets/theme/base/colors";
import {chartColors, DefaultChartData} from "qqq/components/widgets/charts/DefaultChartData";
import ChartSubheaderWithData, {ChartSubheaderData} from "qqq/components/widgets/components/ChartSubheaderWithData";
ChartJS.register(
CategoryScale,
@ -39,18 +40,61 @@ ChartJS.register(
);
export const options = {
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
{
return ("");
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {offset: false},
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
};
@ -58,10 +102,12 @@ export const options = {
interface Props
{
data: DefaultChartData;
chartSubheaderData?: ChartSubheaderData;
}
const {gradients} = colors;
function StackedBarChart({data}: Props): JSX.Element
function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
{
const navigate = useNavigate();
@ -70,23 +116,30 @@ function StackedBarChart({data}: 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)
{
dataset.backgroundColor = gradients[chartColors[index]].state;
if (gradients[chartColors[index]])
{
dataset.backgroundColor = gradients[chartColors[index]].state;
}
else
{
dataset.backgroundColor = chartColors[index];
}
}
});
setStateData(stateData);
@ -95,8 +148,13 @@ function StackedBarChart({data}: Props): JSX.Element
return data ? (
<Box p={3}><Bar data={data} options={options} getElementsAtEvent={handleClick} /></Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} /> ;
<Box>
{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"}} />;
}
export default StackedBarChart;

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ 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 //
@ -51,25 +52,29 @@ interface Props
{
description?: string;
chartData: PieChartData;
chartSubheaderData?: ChartSubheaderData;
[key: string]: any;
}
function PieChart({description, chartData}: Props): JSX.Element
function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Element
{
const navigate = useNavigate();
const [dataLoaded, setDataLoaded] = useState(false);
if (chartData && chartData.dataset)
{
chartData.dataset.backgroundColors = chartColors;
if(!chartData.dataset.backgroundColors)
{
chartData.dataset.backgroundColors = chartColors;
}
}
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {});
useEffect(() =>
{
if(chartData)
if (chartData)
{
setDataLoaded(true);
}
@ -77,53 +82,51 @@ function PieChart({description, chartData}: Props): JSX.Element
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={{minHeight: "400px", boxShadow: "none", height: "100%", width: "100%", display: "flex", flexGrow: 1}}>
<Box mt={3}>
<Grid container alignItems="center">
<Grid item xs={12} justifyContent="center">
<Box width="100%" height="80%" py={2} pr={2} pl={2}>
{useMemo(
() => (
<Pie data={data} options={options} getElementsAtEvent={handleClick} />
),
[chartData]
)}
<Card sx={{boxShadow: "none", height: "100%", width: "100%", display: "flex", flexGrow: 1, border: 0}}>
<Box>
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
</Box>
<Box width="100%" height="300px">
{useMemo(
() => (
<Pie data={data} options={options} getElementsAtEvent={handleClick} />
),
[chartData]
)}
</Box>
{
!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" />
</Box>
{
! 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"/>
</Box>
)
}
</Grid>
</Grid>
<Divider />
)
}
{
description && (
<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>
<>
<Divider />
<Box display="flex" flexDirection={{xs: "column", sm: "row"}} mt="auto">
<MDTypography variant="button" color="text" fontWeight="light">
{parse(description)}
</MDTypography>
</Box>
</>
)
}
</Box>

View File

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

View File

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

View File

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

View File

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

View File

@ -286,18 +286,15 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
sx={{m: 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>
<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>
<TabPanel index={0} value={selectedTab}>
<Grid container>

View File

@ -22,10 +22,15 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import 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} from "react-router-dom";
import Widget, {AddNewRecordButton, ExportDataButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget";
import {useNavigate, Link} from "react-router-dom";
import Widget, {AddNewRecordButton, 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";
@ -47,6 +52,8 @@ 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(() =>
@ -75,6 +82,7 @@ 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)));
////////////////////////////////////////////////////////////////
@ -95,39 +103,42 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
setRows(rows);
setRecords(records)
setColumns(columns);
}
}, [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++)
let csv = "";
for (let i = 0; i < allColumns.length; i++)
{
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field)
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`
}
csv += "\n";
}
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
HtmlUtils.download(fileName, csv);
}
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]);
///////////////////
// view all link //
///////////////////
const labelAdditionalComponentsLeft: LabelComponent[] = []
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(data && data.viewAllLink)
{
labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink));
labelAdditionalElementsLeft.push(
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
}
///////////////////
@ -149,7 +160,26 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
}
}
labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle))
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">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
////////////////////
// add new button //
@ -184,43 +214,51 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
<Widget
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<DataGridPro
autoHeight
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
// components={{Toolbar: CustomToolbar, Pagination: CustomPagination, LoadingOverlay: Loading}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
// density={density}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
<Box mx={-2} mb={-3}>
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
// components={{Toolbar: CustomToolbar, Pagination: CustomPagination, LoadingOverlay: Loading}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
// density={density}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
</Box>
</Widget>
);
}

View File

@ -392,14 +392,8 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
return JSON.stringify(new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])]));
}
/*
position: relative;
left: -356px;
width: calc(100% + 380px);
*/
return (
<Grid container className="scriptViewer">
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
<Grid item xs={12}>
<Box>
{
@ -430,20 +424,17 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
<Typography variant="h5" p={2}></Typography>
<Tabs
sx={{m: 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>
<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>
<TabPanel index={0} value={selectedTab}>
<Grid container>
@ -498,7 +489,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
editorProps={{$blockScrolling: true}}
setOptions={{useWorker: false}}
width="100%"
height="368px"
height="400px"
value={getSelectedFileCode()}
style={{borderTop: "1px solid lightgray", borderBottomRightRadius: "1rem"}}
/>

View File

@ -30,7 +30,8 @@ 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} from "react-table";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable, useExpanded} from "react-table";
import colors from "qqq/assets/theme/base/colors";
import MDInput from "qqq/components/legacy/MDInput";
import MDPagination from "qqq/components/legacy/MDPagination";
import MDTypography from "qqq/components/legacy/MDTypography";
@ -47,6 +48,8 @@ interface Props
canSearch?: boolean;
showTotalEntries?: boolean;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
table: TableDataInput;
pagination?: {
variant: "contained" | "gradient";
@ -56,6 +59,18 @@ 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}} />
))({
@ -71,6 +86,8 @@ function DataTable({
hidePaginationDropdown,
canSearch,
showTotalEntries,
fixedStickyLastRow,
fixedHeight,
table,
pagination,
isSorted,
@ -83,8 +100,77 @@ function DataTable({
defaultValue = (entriesPerPage) ? entriesPerPage : "10";
entries = entriesPerPageOptions ? entriesPerPageOptions : ["10", "25", "50", "100"];
const columns = useMemo<any>(() => table.columns, [table]);
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 data = useMemo<any>(() => table.rows, [table]);
const gridTemplateColumns = widths.join(" ");
if (!columns || !data)
{
@ -95,6 +181,7 @@ function DataTable({
{columns, data, initialState: {pageIndex: 0}},
useGlobalFilter,
useSortBy,
useExpanded,
usePagination
);
@ -113,7 +200,7 @@ function DataTable({
previousPage,
setPageSize,
setGlobalFilter,
state: {pageIndex, pageSize, globalFilter},
state: {pageIndex, pageSize, globalFilter, expanded},
}: any = tableInstance;
// Set the default value for the entries per page when component mounts
@ -193,11 +280,131 @@ function DataTable({
entriesEnd = pageSize * (pageIndex + 1);
}
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
{
boxStyle = isFooter
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, overflow: "auto", scrollbarGutter: "stable"}
: {height: fixedHeight ? `${fixedHeight}px` : "360px", overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
}
return <Box sx={boxStyle}>
<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>
))}
</Box>
)
}
<TableBody {...getTableBodyProps()}>
{rows.map((row: any, key: any) =>
{
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if(row.depth > 0)
{
overrideNoEndBorder = true;
if(key + 1 < rows.length && rows[key + 1].depth == 0)
{
overrideNoEndBorder = false;
}
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if(isFooter)
{
overrideNoEndBorder = true;
}
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: (row.depth > 0 ? "#FAFAFA" : "initial")}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder || overrideNoEndBorder}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value)}</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
}
return (
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && ! hidePaginationDropdown) || canSearch) ? (
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || ! hidePaginationDropdown) && (
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (
<Box display="flex" alignItems="center">
<Autocomplete
disableClearable
@ -205,7 +412,7 @@ function DataTable({
options={entries}
onChange={(event, newValues: any) =>
{
if(typeof newValues === "string")
if (typeof newValues === "string")
{
setEntriesPerPage(parseInt(newValues, 10));
}
@ -240,82 +447,15 @@ function DataTable({
)}
</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()}>
{page.map((row: any, key: any) =>
{
prepareRow(row);
return (
<TableRow sx={{verticalAlign: "top"}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder && rows.length - 1 === key}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell>{parse(cell.value)}</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
</Table>
{
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"
@ -368,15 +508,4 @@ 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,11 +54,13 @@ interface Props
noRowsFoundHTML?: string;
rowsPerPage?: number;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
data: TableDataInput;
}
const qController = Client.getInstance();
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}: Props): JSX.Element
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown, fixedStickyLastRow, fixedHeight}: Props): JSX.Element
{
const [qInstance, setQInstance] = useState(null as QInstance);
@ -72,16 +74,17 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}:
}, []);
return (
<Box py={1}>
<Box py={1} mx={-2}>
{
data && data.columns && !noRowsFoundHTML ?
<DataTable
table={data}
entriesPerPage={rowsPerPage}
hidePaginationDropdown={hidePaginationDropdown}
fixedStickyLastRow={fixedStickyLastRow}
fixedHeight={fixedHeight}
showTotalEntries={false}
isSorted={false}
noEndBorder
/>
: noRowsFoundHTML ?
<Box p={3} pt={1} pb={1} sx={{textAlign: "center"}}>
@ -114,7 +117,7 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}:
<TableRow sx={{verticalAlign: "top"}} key={`row-${i}`}>
{Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell><Skeleton /></DefaultCell>
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
</DataTableBodyCell>
)}
</TableRow>

View File

@ -21,11 +21,16 @@
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 colors from "qqq/assets/theme/base/colors";
import TableCard from "qqq/components/widgets/tables/TableCard";
import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget";
import Widget, {WidgetData} from "qqq/components/widgets/Widget";
import HtmlUtils from "qqq/utils/HtmlUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -43,6 +48,8 @@ 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;
@ -56,14 +63,8 @@ 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,16 +99,37 @@ function TableWidget(props: Props): JSX.Element
csv += "\n";
}
console.log(csv);
setCsv(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.25rem">
<Tooltip title="Export"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}><Icon sx={{color: colors.gray.main, fontSize: 1.125}}>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
return (
<Widget
@ -116,12 +138,14 @@ function TableWidget(props: Props): JSX.Element
reloadWidgetCallback={(data) => props.reloadWidgetCallback(data)}
footerHTML={props.widgetData?.footerHTML}
isChild={props.isChild}
labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
>
<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

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

View File

@ -23,6 +23,7 @@ import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {Theme} from "@mui/material/styles";
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors";
import {useMaterialUIController} from "qqq/context";
// Declaring props types for DataTableHeadCell
@ -46,18 +47,28 @@ function DataTableHeadCell({width, children, sorted, align, ...rest}: Props): JS
py={1.5}
px={3}
sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({
borderBottom: `${borderWidth[1]} solid ${light.main}`,
borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`,
"&:nth-child(1)": {
paddingLeft: "1rem"
},
"&:last-child": {
paddingRight: "1rem"
},
})}
>
<Box
{...rest}
sx={({typography: {size, fontWeightBold}}: Theme) => ({
position: "relative",
opacity: "0.7",
color: colors.grey[700],
textAlign: align,
fontSize: size.xxs,
fontWeight: fontWeightBold,
textTransform: "uppercase",
"@media (min-width: 1440px)": {
fontSize: "1rem"
},
"@media (max-width: 1440px)": {
fontSize: "0.875rem"
},
fontWeight: 600,
cursor: sorted && "pointer",
userSelect: sorted && "none",
})}

View File

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

View File

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

View File

@ -204,7 +204,9 @@ function AppHome({app}: Props): JSX.Element
<BaseLayout>
<Box>
{app.widgets && (
<DashboardWidgets widgetMetaDataList={widgets} />
<Box pb={app.sections ? 2.375 : 0}>
<DashboardWidgets widgetMetaDataList={widgets} />
</Box>
)}
<Grid container spacing={3}>
{

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. //
// todo - this could be simplified, i think? //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
let xhr = new XMLHttpRequest();
@ -237,12 +237,6 @@ 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);
@ -329,7 +323,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 elevation={5}>
<Card>
<Box p={3}>
<MDTypography variant="h5" component="div">
Working
@ -420,228 +414,238 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
//////////////////////////////////////////////////
// render all of the components for this screen //
//////////////////////////////////////////////////
step.components && (step.components.map((component: QFrontendComponent, index: number) => (
<div key={index}>
{
component.type === QComponentType.HELP_TEXT && (
component.values.previewText ?
<>
<Box mt={1}>
<Button onClick={toggleShowFullHelpText} startIcon={<Icon>{showFullHelpText ? "expand_less" : "expand_more"}</Icon>} sx={{pl: 1}}>
{showFullHelpText ? "Hide " : "Show "}
{component.values.previewText}
</Button>
</Box>
<Box mt={1} style={{display: showFullHelpText ? "block" : "none"}}>
<Typography variant="body2" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</Typography>
</Box>
</>
:
<MDTypography variant="button" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</MDTypography>
)
}
{
component.type === QComponentType.BULK_EDIT_FORM && (
tableMetaData && localTableSections ?
<Grid container spacing={3} mt={2}>
{
localTableSections.length == 0 &&
<Grid item xs={12}>
<Alert color="error">There are no editable fields on this table.</Alert>
step.components && (step.components.map((component: QFrontendComponent, index: number) =>
{
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]
if(component.type == QComponentType.BULK_EDIT_FORM)
{
helpRoles = ["EDIT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]
}
return (
<div key={index}>
{
component.type === QComponentType.HELP_TEXT && (
component.values.previewText ?
<>
<Box mt={1}>
<Button onClick={toggleShowFullHelpText} startIcon={<Icon>{showFullHelpText ? "expand_less" : "expand_more"}</Icon>} sx={{pl: 1}}>
{showFullHelpText ? "Hide " : "Show "}
{component.values.previewText}
</Button>
</Box>
<Box mt={1} style={{display: showFullHelpText ? "block" : "none"}}>
<Typography variant="body2" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</Typography>
</Box>
</>
:
<MDTypography variant="button" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</MDTypography>
)
}
{
component.type === QComponentType.BULK_EDIT_FORM && (
tableMetaData && localTableSections ?
<Grid container spacing={3} mt={2}>
{
localTableSections.length == 0 &&
<Grid item xs={12}>
<Alert color="error">There are no editable fields on this table.</Alert>
</Grid>
}
<Grid item xs={12} lg={3}>
{
localTableSections.length > 0 && <QRecordSidebar tableSections={localTableSections} stickyTop="20px" />
}
</Grid>
}
<Grid item xs={12} lg={3}>
{
localTableSections.length > 0 && <QRecordSidebar tableSections={localTableSections} stickyTop="20px" />
}
</Grid>
<Grid item xs={12} lg={9}>
{localTableSections.map((section: QTableSection, index: number) =>
{
const name = section.name;
if (section.isHidden)
<Grid item xs={12} lg={9}>
{
return;
}
const sectionFormFields = {};
for (let i = 0; i < section.fieldNames.length; i++)
{
const fieldName = section.fieldNames[i];
if (formFields[fieldName])
localTableSections.map((section: QTableSection, index: number) =>
{
// @ts-ignore
sectionFormFields[fieldName] = formFields[fieldName];
}
}
const name = section.name;
if (Object.keys(sectionFormFields).length > 0)
{
const sectionFormData = {
formFields: sectionFormFields,
values: values,
errors: errors,
touched: touched
};
if (section.isHidden)
{
return;
}
return (
<Box key={name} pb={3}>
<Card id={name} sx={{scrollMarginTop: "20px"}} elevation={5}>
<MDTypography variant="h5" p={3} pb={1}>
{section.label}
</MDTypography>
<Box px={2}>
<QDynamicForm formData={sectionFormData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
const sectionFormFields = {};
for (let i = 0; i < section.fieldNames.length; i++)
{
const fieldName = section.fieldNames[i];
if (formFields[fieldName])
{
// @ts-ignore
sectionFormFields[fieldName] = formFields[fieldName];
}
}
if (Object.keys(sectionFormFields).length > 0)
{
const sectionFormData = {
formFields: sectionFormFields,
values: values,
errors: errors,
touched: touched
};
return (
<Box key={name} pb={3}>
<Card id={name} sx={{scrollMarginTop: "20px"}} elevation={5}>
<MDTypography variant="h5" p={3} pb={1}>
{section.label}
</MDTypography>
<Box px={2}>
<QDynamicForm formData={sectionFormData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableMetaData?.name};`} />
</Box>
</Card>
</Box>
</Card>
</Box>
);
);
}
else
{
return (<br />);
}
})
}
else
{
return (<br />);
}
}
)}
</Grid>
</Grid>
</Grid>
: <QDynamicForm formData={formData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
)
}
{
component.type === QComponentType.EDIT_FORM && (
<QDynamicForm formData={formData} />
)
}
{
component.type === QComponentType.VIEW_FORM && step.viewFields && (
<div>
{step.viewFields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
processValues[field.name] && (
: <QDynamicForm formData={formData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} helpRoles={helpRoles} helpContentKeyPrefix={`table:${tableMetaData?.name};`} />
)
}
{
component.type === QComponentType.EDIT_FORM && (
<QDynamicForm formData={formData} helpRoles={helpRoles} helpContentKeyPrefix={`process:${processName};`} />
)
}
{
component.type === QComponentType.VIEW_FORM && step.viewFields && (
<div>
{step.viewFields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
processValues[field.name] && (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)
) : (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)
) : (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
)))
}
</div>
)
}
{
component.type === QComponentType.DOWNLOAD_FORM && (
<Grid container display="flex" justifyContent="center">
<Grid item xs={12} sm={12} xl={8} m={3} p={3} mt={6} sx={{border: "1px solid gray", borderRadius: "1rem"}}>
<Box mx={2} mt={-6} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
Download
</Box>
<Box display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold" onClick={() => download(`/download/${processValues.downloadFileName}?filePath=${processValues.serverFilePath}`, processValues.downloadFileName)} sx={{cursor: "pointer"}}>
<Box display="flex" alignItems="center" gap={1} py={1} pr={2}>
<Icon fontSize="large">download_for_offline</Icon>
{processValues.downloadFileName}
</Box>
</MDTypography>
</Box>
)))
}
</div>
)
}
{
component.type === QComponentType.DOWNLOAD_FORM && (
<Grid container display="flex" justifyContent="center">
<Grid item xs={12} sm={12} xl={8} m={3} p={3} mt={6} sx={{border: "1px solid gray", borderRadius: "1rem"}}>
<Box mx={2} mt={-6} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
Download
</Box>
<Box display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold" onClick={() => download(`/download/${processValues.downloadFileName}?filePath=${processValues.serverFilePath}`, processValues.downloadFileName)} sx={{cursor: "pointer"}}>
<Box display="flex" alignItems="center" gap={1} py={1} pr={2}>
<Icon fontSize="large">download_for_offline</Icon>
{processValues.downloadFileName}
</Box>
</MDTypography>
</Box>
</Grid>
</Grid>
</Grid>
)
}
{
component.type === QComponentType.VALIDATION_REVIEW_SCREEN && (
<ValidationReview
qInstance={qInstance}
process={processMetaData}
table={tableMetaData}
processValues={processValues}
step={step}
previewRecords={records}
formValues={formData.values}
doFullValidationRadioChangedHandler={(event: any) =>
{
const {value} = event.currentTarget;
)
}
{
component.type === QComponentType.VALIDATION_REVIEW_SCREEN && (
<ValidationReview
qInstance={qInstance}
process={processMetaData}
table={tableMetaData}
processValues={processValues}
step={step}
previewRecords={records}
formValues={formData.values}
doFullValidationRadioChangedHandler={(event: any) =>
{
const {value} = event.currentTarget;
//////////////////////////////////////////////////////////////
// call the formik function to set the value in this field. //
//////////////////////////////////////////////////////////////
setFieldValue("doFullValidation", value);
//////////////////////////////////////////////////////////////
// call the formik function to set the value in this field. //
//////////////////////////////////////////////////////////////
setFieldValue("doFullValidation", value);
setOverrideOnLastStep(value !== "true");
}}
/>
)
}
{
component.type === QComponentType.PROCESS_SUMMARY_RESULTS && (
<ProcessSummaryResults qInstance={qInstance} process={processMetaData} table={tableMetaData} processValues={processValues} step={step} />
)
}
{
component.type === QComponentType.GOOGLE_DRIVE_SELECT_FOLDER && (
// todo - make these booleans configurable (values on the component)
<GoogleDriveFolderPickerWrapper showSharedDrivesView={true} showDefaultFoldersView={false} qInstance={qInstance} />
)
}
{
component.type === QComponentType.RECORD_LIST && step.recordListFields && recordConfig.columns && (
<div>
<MDTypography variant="button" fontWeight="bold">Records</MDTypography>
{" "}
<br />
<Box height="100%">
<DataGridPro
components={{Pagination: CustomPagination}}
page={recordConfig.pageNo}
disableSelectionOnClick
autoHeight
rows={recordConfig.rows}
columns={recordConfig.columns}
rowBuffer={10}
rowCount={recordConfig.totalRecords}
pageSize={recordConfig.rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
onPageSizeChange={recordConfig.handleRowsPerPageChange}
onPageChange={recordConfig.handlePageChange}
onRowClick={recordConfig.handleRowClick}
getRowId={(row) => row.__idForDataGridPro__}
paginationMode="server"
pagination
density="compact"
loading={recordConfig.loading}
disableColumnFilter
/>
setOverrideOnLastStep(value !== "true");
}}
/>
)
}
{
component.type === QComponentType.PROCESS_SUMMARY_RESULTS && (
<ProcessSummaryResults qInstance={qInstance} process={processMetaData} table={tableMetaData} processValues={processValues} step={step} />
)
}
{
component.type === QComponentType.GOOGLE_DRIVE_SELECT_FOLDER && (
// todo - make these booleans configurable (values on the component)
<GoogleDriveFolderPickerWrapper showSharedDrivesView={true} showDefaultFoldersView={false} qInstance={qInstance} />
)
}
{
component.type === QComponentType.RECORD_LIST && step.recordListFields && recordConfig.columns && (
<div>
<MDTypography variant="button" fontWeight="bold">Records</MDTypography>
{" "}
<br />
<Box height="100%">
<DataGridPro
components={{Pagination: CustomPagination}}
page={recordConfig.pageNo}
disableSelectionOnClick
autoHeight
rows={recordConfig.rows}
columns={recordConfig.columns}
rowBuffer={10}
rowCount={recordConfig.totalRecords}
pageSize={recordConfig.rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
onPageSizeChange={recordConfig.handleRowsPerPageChange}
onPageChange={recordConfig.handlePageChange}
onRowClick={recordConfig.handleRowClick}
getRowId={(row) => row.__idForDataGridPro__}
paginationMode="server"
pagination
density="compact"
loading={recordConfig.loading}
disableColumnFilter
/>
</Box>
</div>
)
}
{
component.type === QComponentType.HTML && (
processValues[`${step.name}.html`] &&
<Box fontSize="1rem">
{parse(processValues[`${step.name}.html`])}
</Box>
</div>
)
}
{
component.type === QComponentType.HTML && (
processValues[`${step.name}.html`] &&
<Box fontSize="1rem">
{parse(processValues[`${step.name}.html`])}
</Box>
)
}
</div>
)))}
)
}
</div>
);
}))
}
</>
);
};
@ -1284,6 +1288,7 @@ 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

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

View File

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

View File

@ -1118,7 +1118,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
exportWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "Roboto","Helvetica","Arial",sans-serif; }
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
@ -1136,8 +1136,6 @@ 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>
@ -2019,7 +2017,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
page={pageNumber}
checkboxSelection
disableSelectionOnClick
autoHeight
autoHeight={false}
rows={rows}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
columns={columnsModel}
@ -2043,6 +2041,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
getRowId={(row) => row.__rowIndex}
selectionModel={rowSelectionModel}
hideFooterSelectedRowCount={true}
sx={{border: 0, height: "calc(100vh - 250px)"}}
/>
</Box>
</Card>

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={1}>{field?.label}</Typography>
<Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}>
{scriptId ?

View File

@ -45,13 +45,16 @@ import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import React, {useContext, useEffect, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import AuditBody from "qqq/components/audits/AuditBody";
import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton} from "qqq/components/buttons/DefaultButtons";
import EntityForm from "qqq/components/forms/EntityForm";
import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
import BaseLayout from "qqq/layouts/BaseLayout";
@ -98,6 +101,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const [metaData, setMetaData] = useState(null as QInstance);
const [record, setRecord] = useState(null as QRecord);
const [tableSections, setTableSections] = useState([] as QTableSection[]);
const [t1Section, setT1Section] = useState(null as QTableSection);
const [t1SectionName, setT1SectionName] = useState(null as string);
const [t1SectionElement, setT1SectionElement] = useState(null as JSX.Element);
const [nonT1TableSections, setNonT1TableSections] = useState([] as QTableSection[]);
@ -117,7 +121,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget);
const closeActionsMenu = () => setActionsMenu(null);
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive} = useContext(QContext);
if (localStorage.getItem(tableVariantLocalStorageKey))
{
@ -351,6 +355,23 @@ function RecordView({table, launchProcess}: Props): JSX.Element
return (visibleJoinTables);
};
/*******************************************************************************
** get an element (or empty) to use as help content for a section
*******************************************************************************/
const getSectionHelp = (section: QTableSection) =>
{
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
const formattedHelpContent = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableName};section:${section.name}`} />;
return formattedHelpContent && (
<Box px={"1.5rem"} fontSize={"0.875rem"} color={colors.blueGray.main}>
{formattedHelpContent}
</Box>
)
}
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
@ -502,15 +523,24 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{
let [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let label = field.label;
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableName};field:${fieldName}`} />;
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{label}:</Typography>
return (
<Box key={fieldName} flexDirection="row" pr={2}>
<Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)">
{label}:
<>
{
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
</Typography>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</>
</Box>
)
})
@ -531,6 +561,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
<Typography variant="h6" p={3} pb={1}>
{section.label}
</Typography>
{getSectionHelp(section)}
<Box p={3} pt={0} flexDirection="column">
{fields}
</Box>
@ -549,6 +580,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{
setT1SectionElement(sectionFieldElements.get(section.name));
setT1SectionName(section.name);
setT1Section(section);
}
else
{
@ -879,6 +911,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{renderActionsMenu}
</Box>
</Box>
{t1Section && getSectionHelp(t1Section)}
{t1SectionElement ? (<Box p={3} pt={0}>{t1SectionElement}</Box>) : null}
</Card>
</Grid>

View File

@ -100,9 +100,12 @@
}
/* move the green check / red x down to align with the calendar icon */
.MuiFormControl-root
.MuiFormControl-root:has(input[type="datetime-local"]),
.MuiFormControl-root:has(input[type="date"]),
.MuiFormControl-root:has(input[type="time"]),
.MuiFormControl-root:has(.MuiInputBase-inputAdornedEnd)
{
background-position-y: 1.4rem !important;
background-position: right 2rem center;
}
.MuiInputAdornment-sizeMedium *
@ -564,3 +567,33 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
display: inline;
right: .5rem
}
/* help-content */
.helpContent
{
color: #757575;
}
.helpContent .header
{
color: #212121;
font-weight: 500;
display: block;
margin-bottom: 0.25rem;
}
.MuiTooltip-tooltip .helpContent P + P
{
margin-top: 1rem;
}
.helpContent UL
{
margin-left: 1rem;
}
/* for query screen column-header tooltips, move them up a little bit, to be more closely attached to the text. */
.dataGridHeaderTooltip
{
top: -1.25rem;
}

View File

@ -25,10 +25,13 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {GridColDef, GridFilterItem, GridRowsProp} from "@mui/x-data-grid-pro";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
import React from "react";
import {Link} from "react-router-dom";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -310,6 +313,20 @@ export default class DataGridUtils
(cellValues.value)
);
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
const showHelp = hasHelpContent(field.helpContents, helpRoles); // todo - maybe - take helpHelpActive from context all the way down to here?
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={headerName} helpContentKey={`table:${tableMetaData.name};field:${fieldName}`} />;
column.renderHeader = (params: GridColumnHeaderParams) => (
<Tooltip title={formattedHelpContent}>
<div className="MuiDataGrid-columnHeaderTitle" style={{lineHeight: "initial"}}>
{headerName}
</div>
</Tooltip>
);
}
return (column);
}

View File

@ -63,6 +63,10 @@ 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) =>
{
@ -95,18 +99,6 @@ 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");
@ -118,15 +110,16 @@ 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">
@ -141,7 +134,7 @@ export default class HtmlUtils
openInWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "Roboto","Helvetica","Arial",sans-serif; }
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
@ -154,7 +147,6 @@ 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,11 +29,18 @@ 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);
}
@ -46,6 +53,11 @@ class Client
return this.qController;
}
static setUnauthorizedCallback(unauthorizedCallback: () => void)
{
Client.unauthorizedCallback = unauthorizedCallback;
}
}
export default Client;

View File

@ -1,6 +1,11 @@
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;
@ -11,6 +16,7 @@ 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;
/*******************************************************************************
@ -40,7 +46,7 @@ public class QBaseSeleniumTest
String headless = System.getenv("QQQ_SELENIUM_HEADLESS");
if("true".equals(headless))
{
chromeOptions.setHeadless(true);
chromeOptions.addArguments("--headless=new");
}
WebDriverManager.chromiumdriver().setup();
@ -54,7 +60,15 @@ 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);
@ -68,6 +82,57 @@ 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,3 +1,24 @@
/*
* 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;
@ -7,7 +28,7 @@ package com.kingsrook.qqq.materialdashboard.lib;
public interface QQQMaterialDashboardSelectors
{
String SIDEBAR_ITEM = ".MuiDrawer-paperAnchorDockedLeft li.MuiListItem-root";
String BREADCRUMB_HEADER = ".MuiToolbar-root h5";
String BREADCRUMB_HEADER = ".MuiToolbar-root h3";
String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent";
String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input";

View File

@ -1,3 +1,24 @@
/*
* 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;
@ -6,11 +27,15 @@ 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;
@ -36,6 +61,8 @@ public class QSeleniumLib
private boolean SCREENSHOTS_ENABLED = true;
private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/";
private boolean autoHighlight = false;
/*******************************************************************************
@ -187,7 +214,13 @@ public class QSeleniumLib
*******************************************************************************/
public WebElement waitForSelector(String cssSelector)
{
return (waitForSelectorAll(cssSelector, 1).get(0));
WebElement element = waitForSelectorAll(cssSelector, 1).get(0);
Actions actions = new Actions(driver);
actions.moveToElement(element);
conditionallyAutoHighlight(element);
return element;
}
@ -230,7 +263,7 @@ public class QSeleniumLib
do
{
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.size() == 0)
if(elements.isEmpty())
{
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return;
@ -256,7 +289,7 @@ public class QSeleniumLib
do
{
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.size() == 0)
if(elements.isEmpty())
{
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return;
@ -330,6 +363,22 @@ 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);
});
}
/*******************************************************************************
**
*******************************************************************************/
@ -380,7 +429,10 @@ public class QSeleniumLib
@FunctionalInterface
public interface Code<T>
{
public T run();
/*******************************************************************************
**
*******************************************************************************/
T run();
}
@ -430,6 +482,7 @@ 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);
}
}
@ -437,6 +490,10 @@ public class QSeleniumLib
{
LOG.debug("Caught a StaleElementReferenceException - will retry.");
}
catch(NoSuchElementException nsee)
{
LOG.debug("Caught a NoSuchElementException - will retry.");
}
}
sleepABit();
@ -449,6 +506,20 @@ 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.
@ -478,7 +549,8 @@ public class QSeleniumLib
destFile.mkdirs();
if(destFile.exists())
{
destFile.delete();
String newFileName = destFile.getAbsolutePath().replaceFirst("\\.png", "-" + System.currentTimeMillis() + ".png");
destFile.renameTo(new File(newFileName));
}
FileUtils.moveFile(outputFile, destFile);
LOG.info("Made screenshot at: " + destFile);
@ -555,4 +627,48 @@ 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,7 +29,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Test for Associated Record Scripts functionality.
** 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).
*******************************************************************************/
public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
{

View File

@ -0,0 +1,110 @@
/*
* 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.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);
qSeleniumLib.waitForCondition("Expected file name", () -> getDownloadedFiles().get(0).getName().matches("Sample Table Widget.*.csv"));
File csvFile = getDownloadedFiles().get(0);
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

@ -110,7 +110,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest
//////////////////////
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
qSeleniumLib.waitForSelectorContaining("H5", "Person").click();
qSeleniumLib.waitForSelectorContaining("H3", "Person").click();
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
.findElement(By.cssSelector("CIRCLE"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2");

View File

@ -302,8 +302,7 @@
"label": "Greetings App",
"iconName": "emoji_people",
"widgets": [
"PersonsByCreateDateBarChart",
"QuickSightChartRenderer"
"SampleTableWidget"
],
"children": [
{
@ -738,139 +737,11 @@
"icon": "/kr-icon.png"
},
"widgets": {
"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",
"SampleTableWidget": {
"name": "SampleTableWidget",
"label": "Sample Table Widget",
"type": "table",
"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"
"showExportButton": true
},
"scriptViewer": {
"name": "scriptViewer",