Compare commits

...

70 Commits

Author SHA1 Message Date
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
f41b71d3c7 Support combination of non-query table (so goto only) plus variants. 2023-09-29 17:10:06 -05:00
57fefe9671 Merge pull request #33 from Kingsrook/feature/fix-urlencoding-primary-keys
Feature/fix urlencoding primary keys (and some filters)
2023-09-26 10:16:44 -05:00
8a018c34f6 Merge branch 'main' into feature/fix-urlencoding-primary-keys 2023-09-26 08:32:17 -05:00
1b4f70a547 Merge pull request #32 from Kingsrook/feature/CE-609-infrastructure-remove-permissions-from-header
Feature/ce 609 infrastructure remove permissions from header
2023-09-25 16:28:33 -05:00
1f343abbb5 Switch to use jwt_decode library (from auth0) rather than S/O decodeJWT function 2023-09-25 16:27:46 -05:00
37a18bbe0d Add some urlencoding of primary keys in query, view, and run process; updated qqq-frontend-core to also do more urlencoding 2023-09-25 13:33:20 -05:00
98cc2ceb00 Merge remote-tracking branch 'origin/feature/deploy-test-jar' 2023-09-22 10:35:05 -05:00
e351883d73 add check for table 2023-09-22 09:46:00 -05:00
25599d0ca6 Merge pull request #30 from Kingsrook/feature/extensiv-shipped-order-fix
more updates to allow process to be manually ran
2023-09-21 11:43:43 -05:00
01d18902d7 more updates to allow process to be manually ran 2023-09-20 19:52:13 -05:00
580d4a90c9 Merge remote-tracking branch 'origin/integration/sprint-32' into feature/deploy-test-jar 2023-09-06 16:28:30 -05:00
eeb1b37d18 Trying to fix chrome/orb fun by updating orb version to latest 2023-09-06 08:50:06 -05:00
da0947b538 Trying to fix chrome/orb fun. See https://github.com/CircleCI-Public/browser-tools-orb/issues/75 2023-09-06 08:47:20 -05:00
0c76371d59 Add maven-jar-plugin to publish qfmd's test classes in a jar (e.g., for inclusion in applications for selenium testing); Updates in library classes to support alternative usages 2023-09-06 08:25:17 -05:00
19aebd631a attempt to fix seleniums 2023-08-17 16:48:44 -05:00
5aac9ce069 hotfix: fixed bug where navigating from one record to another, then hitting the 'e' button goes to the edit screen for the previous record 2023-08-17 16:13:50 -05:00
7ea50dd7bb Merge branch 'main' into feature/CE-609-infrastructure-remove-permissions-from-header
# Conflicts:
#	package.json
2023-08-17 11:42:51 -05:00
53d5bc58c1 updated snapshot 2023-08-17 10:18:38 -05:00
eac166b877 circle ci config update 2023-08-17 10:06:44 -05:00
f49ac38e24 changed circle ci to only deploy main automatically 2023-08-17 09:53:57 -05:00
28bdfc19e8 rebuilt package-lock 2023-08-17 09:47:24 -05:00
fa076733fb CE-567 Update version of webdrivermanager, because apparently you have to do that sometimes 2023-08-16 11:46:20 -05:00
8bebef1abe CE-567 Pass table name to init (for table-generic processes that might want it) 2023-08-15 19:44:58 -05:00
37fa578a59 CE-567 show more lines of commit messages 2023-08-15 19:44:13 -05:00
b6b7d8d8b3 CE-609 - Removed DNDTest WIP module 2023-08-15 09:25:45 -05:00
7bf515554d CE-609 - staged-rollout-ready - keeping the auth header, but also setting sessionUUID cookie; placeholder for quick-rollback; added todo#authHeader comments to mark where follow-up needs to happen after happy with new code 2023-08-15 09:08:44 -05:00
069cbf52e1 Merge pull request #28 from Kingsrook/feature/CE-607-mvp-of-transportation-plan-record
CE-607 Support fields from an exposed-join on a view screen.
2023-08-09 12:28:16 -05:00
7fa42a6eb5 Initial WIP Checkpoint of auth0 userSessions 2023-08-09 09:48:22 -05:00
50 changed files with 2858 additions and 1787 deletions

View File

@ -2,7 +2,7 @@ version: 2.1
orbs:
node: circleci/node@5.1.0
browser-tools: circleci/browser-tools@1.4.3
browser-tools: circleci/browser-tools@1.4.5
executors:
java17:
@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
ignore: /dev/
ignore: /main/
tags:
ignore: /(version|snapshot)-.*/
deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
only: /dev/
only: /main/
tags:
only: /(version|snapshot)-.*/

2101
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.80",
"@kingsrook/qqq-frontend-core": "1.0.83",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -33,6 +33,7 @@
"html-react-parser": "1.4.8",
"html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6",
"jwt-decode": "3.1.2",
"rapidoc": "9.3.4",
"react": "18.0.0",
"react-ace": "10.1.0",
@ -56,9 +57,7 @@
"npm-install": "npm install --legacy-peer-deps",
"prepublishOnly": "tsc -p ./ --outDir lib/",
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",
"test": "react-scripts test",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
"test": "react-scripts test"
},
"eslintConfig": {
"extends": [
@ -86,8 +85,6 @@
"@types/react-table": "7.7.9",
"@typescript-eslint/eslint-plugin": "5.10.2",
"@typescript-eslint/parser": "5.10.2",
"cypress": "11.0.1",
"cypress-wait-for-stable-dom": "0.1.0",
"eslint": "8.8.0",
"eslint-import-resolver-typescript": "2.5.0",
"eslint-plugin-import": "2.25.4",

18
pom.xml
View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.18.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>
@ -83,7 +83,7 @@
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.3.1</version>
<version>5.4.1</version>
<scope>test</scope>
</dependency>
<dependency>
@ -161,6 +161,20 @@
<skipUpdateVersion>true</skipUpdateVersion>
</configuration>
</plugin>
<!-- Publish this project's test code as a jar -->
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@ -33,7 +33,8 @@ import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import React, {JSXElementConstructor, Key, ReactElement, useContext, useEffect, useState,} from "react";
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 {Md5} from "ts-md5/dist/md5";
@ -57,11 +58,11 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
const qController = Client.getInstance();
export const SESSION_ID_COOKIE_NAME = "sessionId";
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App()
{
const [, setCookie, removeCookie] = useCookies([SESSION_ID_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);
@ -69,8 +70,70 @@ export default function App()
const [branding, setBranding] = useState({} as QBrandingMetaData);
const [metaData, setMetaData] = useState({} as QInstance);
const [needLicenseKey, setNeedLicenseKey] = useState(true);
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);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if(oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"]
delete newJSON["iat"]
delete oldJSON["exp"]
delete oldJSON["iat"]
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if(different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch(e)
{
console.log("Caught in shouldStoreNewToken: " + e)
}
return (true);
};
useEffect(() =>
{
if (loadingToken)
@ -92,20 +155,38 @@ export default function App()
{
console.log("Loading token from auth0...");
const accessToken = await getAccessTokenSilently();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
/////////////////////////////////////////////////////////////////////////////////
// we've stopped using session id cook with auth0, so make sure it is not set. //
/////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_ID_COOKIE_NAME);
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const newSessionUuid = await qController.manageSession(accessToken, null);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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();
setLoggedInUser(user);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout();
return;
}
@ -116,9 +197,9 @@ export default function App()
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
qController.setAuthorizationHeaderValue(null);
setIsFullyAuthenticated(true);
setCookie(SESSION_ID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
}
@ -149,7 +230,7 @@ export default function App()
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]);
const [appRoutes, setAppRoutes] = useState(null as any);
const [pathToLabelMap, setPathToLabelMap] = useState({} as {[path: string]: string});
const [pathToLabelMap, setPathToLabelMap] = useState({} as { [path: string]: string });
////////////////////////////////////////////
// load qqq meta data to make more routes //
@ -267,14 +348,14 @@ export default function App()
name: `${app.label}`,
key: app.name,
route: path,
component: <RecordQuery table={table} key={table.name}/>,
component: <RecordQuery table={table} key={table.name} />,
});
routeList.push({
name: `${app.label}`,
key: app.name,
route: `${path}/savedFilter/:id`,
component: <RecordQuery table={table} key={table.name}/>,
component: <RecordQuery table={table} key={table.name} />,
});
routeList.push({
@ -429,11 +510,11 @@ export default function App()
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(user?.email || "user");
const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = {
type: "collapse",
name: user?.name,
name: loggedInUser?.name ?? "Anonymous",
key: "username",
noCollapse: true,
icon: <Avatar src={profilePicture} alt="{user?.name}" />,
@ -469,7 +550,7 @@ export default function App()
}
const pathToLabelMap: {[path: string]: string} = {}
for(let i =0; i<appRoutesList.length; i++)
for (let i = 0; i < appRoutesList.length; i++)
{
const route = appRoutesList[i];
pathToLabelMap[route.route] = route.name;
@ -495,7 +576,10 @@ export default function App()
{
if ((e as QException).status === "401")
{
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
//////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic //
@ -596,7 +680,7 @@ export default function App()
}}>
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData}/>
<CommandMenu metaData={metaData} />
<Sidenav
color={sidenavColor}
icon={branding.icon}
@ -604,7 +688,6 @@ export default function App()
appName={branding.appName}
branding={branding}
routes={sideNavRoutes}
pathToLabelMap={pathToLabelMap}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
/>

View File

@ -22,7 +22,7 @@
import {useAuth0} from "@auth0/auth0-react";
import React, {useEffect} from "react";
import {useCookies} from "react-cookie";
import {SESSION_ID_COOKIE_NAME} from "App";
import {SESSION_UUID_COOKIE_NAME} from "App";
interface Props
{
@ -33,13 +33,13 @@ interface Props
function HandleAuthorizationError({errorMessage}: Props)
{
const [, , removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]);
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {logout} = useAuth0();
useEffect(() =>
{
logout();
removeCookie(SESSION_ID_COOKIE_NAME, {path: "/"});
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
});
return (

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

@ -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 ${rgba(black.main, 0.25)}`,
borderRadius: borderRadius.xl,
overflow: "visible",
},

View File

@ -1,68 +1,72 @@
/**
=========================================================
* 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"
},
},
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: "rgba(0, 0, 0, 0.2) 0px 3px 3px -2px, rgba(0, 0, 0, 0.14) 0px 3px 4px 0px, rgba(0, 0, 0, 0.12) 0px 1px 8px 0px"
},
arrow: {
color: black.main,
color: white.main,
},
},
};

View File

@ -19,13 +19,14 @@
* 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,
@ -60,6 +61,9 @@ const AntSwitch = styled(Switch)(({theme}) => ({
duration: 200,
}),
},
"&.nullSwitch .MuiSwitch-thumb": {
width: 24,
},
"& .MuiSwitch-track": {
borderRadius: 16 / 2,
opacity: 1,
@ -78,6 +82,7 @@ interface Props
}
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -96,8 +101,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
setFieldValue(name, !value);
}
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
return (
<>
<Box bgcolor={isDisabled ? colors.grey[200] : ""}>
<InputLabel shrink={true}>{label}</InputLabel>
<Stack direction="row" spacing={1} alignItems="center">
<Typography
@ -107,7 +114,7 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
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"
color={value === true ? "auto" : "#bfbfbf"}
@ -116,7 +123,7 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
Yes
</Typography>
</Stack>
</>
</Box>
);
}

View File

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

View File

@ -426,6 +426,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 +443,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 +466,10 @@ 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]);
}
}
}
@ -473,7 +478,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 +511,7 @@ function EntityForm(props: Props): JSX.Element
else
{
await qController
.create(tableName, values)
.create(tableName, valuesToPost)
.then((record) =>
{
if (props.isModal)

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Popper} 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);
@ -162,7 +164,15 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
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

@ -110,11 +110,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 +145,27 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({
display: "none !important",
cursor: "pointer",
[breakpoints.up("xl")]: {
[breakpoints.down("sm")]: {
display: "inline-block !important",
},
});
const recentlyViewedMenu = ({breakpoints}: Theme) => ({
"& .MuiOutlinedInput-root": {
borderRadius: "0",
padding: "0"
},
"& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
border: "0"
},
display: "block",
[breakpoints.down("md")]: {
display: "none !important",
},
});
const navbarMobileMenu = ({breakpoints}: Theme) => ({
left: "-0.75rem",
display: "inline-block",
lineHeight: 0,
@ -167,4 +181,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 = 275;
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

@ -22,6 +22,7 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
@ -45,8 +46,10 @@ interface Props
isOpen: boolean;
metaData: QInstance;
tableMetaData: QTableMetaData;
tableVariant?: QTableVariant;
closeHandler: () => void;
mayClose: boolean;
subHeader?: JSX.Element;
}
GotoRecordDialog.defaultProps = {
@ -155,21 +158,30 @@ function GotoRecordDialog(props: Props): JSX.Element
{
setError("");
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, "AND", null, 10);
const queryResult = await qController.query(props.tableMetaData.name, filter)
if(queryResult.length == 0)
try
{
setError("Record not found.");
setTimeout(() => setError(""), 3000);
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant)
if (queryResult.length == 0)
{
setError("Record not found.");
setTimeout(() => setError(""), 3000);
}
else if (queryResult.length == 1)
{
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
close();
}
else
{
setError("More than 1 record found...");
setTimeout(() => setError(""), 3000);
}
}
else if(queryResult.length == 1)
catch(e)
{
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${queryResult[0].values.get(props.tableMetaData.primaryKeyField)}`);
close();
}
else
{
setError("More than 1 record found...");
setTimeout(() => setError(""), 3000);
// @ts-ignore
setError(`Error: ${(e && e.message) ? e.message : e}`);
setTimeout(() => setError(""), 6000);
}
}
@ -184,7 +196,9 @@ function GotoRecordDialog(props: Props): JSX.Element
return (
<Dialog open={props.isOpen} onClose={() => closeRequested} onKeyPress={(e) => keyPressed(e)} fullWidth maxWidth={"sm"}>
<DialogTitle>Go To...</DialogTitle>
<DialogContent>
{props.subHeader}
{
fields.map((field, index) =>
(
@ -237,9 +251,11 @@ interface GotoRecordButtonProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
tableVariant?: QTableVariant;
autoOpen?: boolean;
buttonVisible?: boolean;
mayClose?: boolean;
subHeader?: JSX.Element;
}
GotoRecordButton.defaultProps = {
@ -268,7 +284,7 @@ export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
{
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto} >Go To...</Button>
}
<GotoRecordDialog metaData={props.metaData} tableMetaData={props.tableMetaData} isOpen={gotoIsOpen} closeHandler={closeGoto} mayClose={props.mayClose} />
<GotoRecordDialog metaData={props.metaData} tableMetaData={props.tableMetaData} tableVariant={props.tableVariant} isOpen={gotoIsOpen} closeHandler={closeGoto} mayClose={props.mayClose} subHeader={props.subHeader} />
</React.Fragment>
);
}

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

@ -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.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>
)
}
@ -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,62 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
</Box>
);
}
};
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: "1rem 0 0 1.5rem", width: "100%", marginBottom: "-1.5rem"}}>
{renderedWidget}
</TabPanel>);
}
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>)
})
}
</>
);
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs
sx={{m: 0, mb: 1.5}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
{widgetMetaDataList.map((widgetMetaData, i) => (
<Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))}
</Tabs>
: <></>
return (
widgetCount > 0 ? (
omitWrappingGridContainer ? body :
(
<Grid container spacing={3} pb={4}>
{body}
</Grid>
)
<>
{tabs}
{
omitWrappingGridContainer ? body : (
<Grid container spacing={3} pb={4}>
{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,10 +92,15 @@ 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);
}
///////////////////////////////////////////////////////////////////////////////////////////
@ -112,7 +118,7 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
reloadWidgetCallback={parentReloadWidgetCallback}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData}/>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType == "TABS"}/>
</Box>
</Widget>
) : null

View File

@ -30,7 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import React, {useEffect, useState} from "react";
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
import {NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
@ -46,6 +46,7 @@ export interface WidgetData
dropdownNeedsSelectedText?: string;
hasPermission?: boolean;
errorLoading?: boolean;
[other: string]: any;
}
@ -53,6 +54,7 @@ export interface WidgetData
interface Props
{
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
@ -70,6 +72,7 @@ Widget.defaultProps = {
widgetMetaData: {},
widgetData: {},
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
};
@ -88,33 +91,50 @@ 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;
color: string;
coloredBG: boolean;
constructor(label: string, to: string)
iconColor: string;
bgColor: string;
constructor(iconName: string, color: string, coloredBG: boolean = true)
{
super();
this.label = label;
this.to = to;
this.iconName = iconName;
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>
);
<Icon sx={{
m: 2,
mr: 0,
mb: 0,
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
backgroundColor: this.bgColor,
padding: "0.25rem",
borderRadius: "0.25rem"
}} fontSize="small">{this.iconName}</Icon>
)
}
}
@ -141,8 +161,8 @@ export class AddNewRecordButton extends LabelComponent
openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) =>
{
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`)
}
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`);
};
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
@ -151,35 +171,7 @@ export class AddNewRecordButton extends LabelComponent
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
</Typography>
);
}
}
/*******************************************************************************
**
*******************************************************************************/
export class ExportDataButton extends LabelComponent
{
callbackToExport: any;
tooltipTitle: string;
isDisabled: boolean;
constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export")
{
super();
this.callbackToExport = callbackToExport;
this.isDisabled = isDisabled;
this.tooltipTitle = tooltipTitle;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title={this.tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callbackToExport()} disabled={this.isDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
};
}
@ -227,7 +219,7 @@ export class Dropdown extends LabelComponent
/>
</Box>
);
}
};
}
@ -251,7 +243,7 @@ export class ReloadControl extends LabelComponent
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
</Typography>
);
}
};
}
@ -372,7 +364,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 +386,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
}
reloadWidget(dropdownData)
reloadWidget(dropdownData);
}
}
@ -422,7 +414,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
}
}
};
const toggleFullScreenWidget = () =>
{
@ -434,14 +426,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,24 +442,42 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission)
{
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (props.labelAdditionalElementsLeft && props.labelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= isSet(props.widgetMetaData?.icon);
needLabelBox ||= isSet(props.widgetData?.label);
needLabelBox ||= isSet(props.widgetMetaData?.label);
}
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
// note - previously this had a ?: and one was pl={2}, the other was pl={3}... //
//////////////////////////////////////////////////////////////////////////////////////////
const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label
let labelElement = (
<Typography sx={{position: "relative", top: -4, cursor: "default"}} variant="h6" fontWeight="medium" display="inline">
{labelToUse}
</Typography>
);
if(props.widgetMetaData.tooltip)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={false} followCursor={true} placement="bottom-start">{labelElement}</Tooltip>
}
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
const widgetContent =
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
{
needLabelBox &&
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} height={"3.5rem"}>
<Box pt={2} pb={1}>
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} minHeight={"3.5rem"}>
<Box pt={2} pb={1} ml={2}>
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box
ml={3}
ml={1}
mr={2}
mt={-4}
sx={{
display: "flex",
@ -507,20 +517,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
)
}
{
//////////////////////////////////////////////////////////////////////////////////////////
// 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 +527,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
})
)
}
{props.labelAdditionalElementsLeft}
</Box>
<Box>
{
@ -579,7 +577,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
{widgetContent}
</Card>
: widgetContent;
: <span style={{width: "100%"}}>{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,40 @@ ChartJS.register(
);
export const options = {
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
plugins: {
tooltip: {
// todo - some configs around this
enabled: false
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 8,
boxWidth: 8,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {offset: false},
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
};
@ -58,10 +81,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 +95,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 +127,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 p={3} pt={1}>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={options} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;
}
export default StackedBarChart;

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,19 +82,22 @@ 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}>
<Card sx={{boxShadow: "none", height: "100%", width: "100%", display: "flex", flexGrow: 1, border: 0}}>
<Box mt={1}>
<Box px={3}>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
</Box>
<Grid container alignItems="center">
<Grid item xs={12} justifyContent="center">
<Box width="100%" height="80%" py={2} pr={2} pl={2}>
<Box width="100%" height="300px" py={2} pr={2} pl={2}>
{useMemo(
() => (
<Pie data={data} options={options} getElementsAtEvent={handleClick} />
@ -98,32 +106,35 @@ function PieChart({description, chartData}: Props): JSX.Element
)}
</Box>
{
! chartData && (
!chartData && (
<Box sx={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
justifyContent: "center"}}>
<Skeleton sx={{width: "150px", height: "150px"}} variant="circular"/>
justifyContent: "center"
}}>
<Skeleton sx={{width: "150px", height: "150px"}} variant="circular" />
</Box>
)
}
</Grid>
</Grid>
<Divider />
{
description && (
<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>
<>
<Divider />
<Grid container>
<Grid item xs={12}>
<Box pb={2} px={2} display="flex" flexDirection={{xs: "column", sm: "row"}} mt="auto">
<MDTypography variant="button" color="text" fontWeight="light">
{parse(description)}
</MDTypography>
</Box>
</Grid>
</Grid>
</Grid>
</>
)
}
</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,33 @@ function configs(labels: any, datasets: any)
],
},
options: {
maintainAspectRatio: true,
maintainAspectRatio: false,
responsive: true,
aspectRatio: 2,
plugins: {
tooltip: {
callbacks: {
label: function(context: any)
{
////////////////////////////////////////////////////////////////////////////////
// our labels already have the value in them - so just use the label in the //
// tooltip (lib by default puts label + value, so we were duplicating value!) //
////////////////////////////////////////////////////////////////////////////////
return context.label;
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
padding: 12,
boxHeight: 8,
boxWidth: 8,
font: {
size: 14
}
}
},
},
scales: {

View File

@ -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="h2" display="inline">{ValueUtils.getFormattedNumber(chartSubheaderData.mainNumber)}</Typography>;
if(chartSubheaderData.mainNumberUrl)
{
mainNumberElement = <Link to={chartSubheaderData.mainNumberUrl}>{mainNumberElement}</Link>
}
mainNumberElement = <Box pr={1}>{mainNumberElement}</Box>
let previousNumberElement = (
<>
<Typography display="inline" variant="body2" sx={{color: colors.black.main}}>
&nbsp;{chartSubheaderData.vsDescription}
{chartSubheaderData.vsPreviousNumber && (<>&nbsp;({ValueUtils.getFormattedNumber(chartSubheaderData.vsPreviousNumber)})</>)}
</Typography>
</>
)
if(chartSubheaderData.previousNumberUrl)
{
previousNumberElement = <Link to={chartSubheaderData.previousNumberUrl}>{previousNumberElement}</Link>
}
return chartSubheaderData ? (
<Box display="inline-flex" alignItems="flex-end" flexWrap="wrap">
{mainNumberElement}
{
chartSubheaderData.vsPreviousPercent != null && iconName != null && (
<Box display="inline-flex" alignItems="flex-end" pb={1} ml={-0.5}>
<Icon fontSize="medium" sx={{color: color}}>{iconName}</Icon>
<Typography display="inline" variant="body2" sx={{color: color}}>{chartSubheaderData.vsPreviousPercent}%</Typography>
{previousNumberElement}
</Box>
)
}
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "12px"}} />;
}
export default StackedBarChart;

View File

@ -216,7 +216,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}}
primary={
<div style={{whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}} title={version.values.get("commitMessage")}>
<div style={{overflow: "hidden", textOverflow: "ellipsis", maxHeight: "5rem"}} title={version.values.get("commitMessage")}>
{currentVersionId == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")}
</div>
@ -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,14 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {useNavigate} 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 +51,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 +81,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 +102,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" top="-0.25rem">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
}
///////////////////
@ -149,7 +159,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" top="-0.375rem">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
////////////////////
// add new button //
@ -184,7 +213,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
<Widget
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
>
<DataGridPro

View File

@ -314,7 +314,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}}
primary={
<div style={{whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}} title={version.values.get("commitMessage")}>
<div style={{overflow: "hidden", textOverflow: "ellipsis", maxHeight: "5rem"}} title={version.values.get("commitMessage")}>
{scriptRecord.values.get("currentScriptRevisionId") == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")}
</div>
@ -430,20 +430,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 +495,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,7 @@ import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import parse from "html-react-parser";
import {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable, useExpanded} from "react-table";
import MDInput from "qqq/components/legacy/MDInput";
import MDPagination from "qqq/components/legacy/MDPagination";
import MDTypography from "qqq/components/legacy/MDTypography";
@ -47,6 +47,8 @@ interface Props
canSearch?: boolean;
showTotalEntries?: boolean;
hidePaginationDropdown?: boolean;
fixedStickyLastRow?: boolean;
fixedHeight?: number;
table: TableDataInput;
pagination?: {
variant: "contained" | "gradient";
@ -56,6 +58,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 +85,8 @@ function DataTable({
hidePaginationDropdown,
canSearch,
showTotalEntries,
fixedStickyLastRow,
fixedHeight,
table,
pagination,
isSorted,
@ -83,8 +99,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 +180,7 @@ function DataTable({
{columns, data, initialState: {pageIndex: 0}},
useGlobalFilter,
useSortBy,
useExpanded,
usePagination
);
@ -113,7 +199,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,79 +279,45 @@ function DataTable({
entriesEnd = pageSize * (pageIndex + 1);
}
return (
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && ! hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || ! hidePaginationDropdown) && (
<Box display="flex" alignItems="center">
<Autocomplete
disableClearable
value={pageSize.toString()}
options={entries}
onChange={(event, newValues: any) =>
{
if(typeof newValues === "string")
{
setEntriesPerPage(parseInt(newValues, 10));
}
else
{
setEntriesPerPage(parseInt(newValues[0], 10));
}
}}
size="small"
sx={{width: "5rem"}}
renderInput={(params) => <MDInput {...params} />}
/>
<MDTypography variant="caption" color="secondary">
&nbsp;&nbsp;entries per page
</MDTypography>
</Box>
)}
{canSearch && (
<Box width="12rem" ml="auto">
<MDInput
placeholder="Search..."
value={search}
size="small"
fullWidth
onChange={({currentTarget}: any) =>
{
setSearch(search);
onSearchChange(currentTarget.value);
}}
/>
</Box>
)}
</Box>
) : null}
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
{
boxStyle = isFooter ? {overflowY: "visible", borderTop: "0.0625rem solid #f0f2f5;"} : {height: fixedHeight ? `${fixedHeight}px` : "360px", overflowY: "auto"};
}
const className = isFooter ? "hideScrollbars" : "";
return <Box sx={boxStyle} className={className}>
<Table {...getTableProps()}>
<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>
)
{
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>
))}
</TableRow>
))}
</Box>
</Box>
)
}
<TableBody {...getTableBodyProps()}>
{page.map((row: any, key: any) =>
{rows.map((row: any, key: any) =>
{
prepareRow(row);
return (
<TableRow sx={{verticalAlign: "top"}} key={key} {...row.getRowProps()}>
<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
@ -308,6 +360,9 @@ function DataTable({
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
@ -316,6 +371,65 @@ function DataTable({
})}
</TableBody>
</Table>
</Box>
}
return (
<TableContainer sx={{boxShadow: "none"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (
<Box display="flex" alignItems="center">
<Autocomplete
disableClearable
value={pageSize.toString()}
options={entries}
onChange={(event, newValues: any) =>
{
if (typeof newValues === "string")
{
setEntriesPerPage(parseInt(newValues, 10));
}
else
{
setEntriesPerPage(parseInt(newValues[0], 10));
}
}}
size="small"
sx={{width: "5rem"}}
renderInput={(params) => <MDInput {...params} />}
/>
<MDTypography variant="caption" color="secondary">
&nbsp;&nbsp;entries per page
</MDTypography>
</Box>
)}
{canSearch && (
<Box width="12rem" ml="auto">
<MDInput
placeholder="Search..."
value={search}
size="small"
fullWidth
onChange={({currentTarget}: any) =>
{
setSearch(search);
onSearchChange(currentTarget.value);
}}
/>
</Box>
)}
</Box>
) : null}
{
fixedStickyLastRow ? (
<>
{getTable(true, page.slice(0, page.length -1), false)}
{getTable(false, page.slice(page.length-1), true)}
</>
) : getTable(true, page, false)
}
<Box
display="flex"
@ -368,15 +482,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);
@ -79,6 +81,8 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown}:
table={data}
entriesPerPage={rowsPerPage}
hidePaginationDropdown={hidePaginationDropdown}
fixedStickyLastRow={fixedStickyLastRow}
fixedHeight={fixedHeight}
showTotalEntries={false}
isSorted={false}
noEndBorder

View File

@ -21,11 +21,15 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
// @ts-ignore
import {htmlToText} from "html-to-text";
import React, {useEffect, useState} from "react";
import TableCard from "qqq/components/widgets/tables/TableCard";
import Widget, {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 +47,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 +62,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 +98,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.375rem">
<Tooltip title="Export"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
return (
<Widget
@ -116,12 +137,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

@ -62,10 +62,12 @@ import {GoogleDriveFolderPickerWrapper} from "qqq/components/processes/GoogleDri
import ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults";
import ValidationReview from "qqq/components/processes/ValidationReview";
import BaseLayout from "qqq/layouts/BaseLayout";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface Props
{
process?: QProcessMetaData;
@ -74,7 +76,7 @@ interface Props
isModal?: boolean;
isWidget?: boolean;
isReport?: boolean;
recordIds?: string | QQueryFilter;
recordIds?: string[] | QQueryFilter;
closeModalHandler?: (event: object, reason: string) => void;
forceReInit?: number;
overrideLabel?: string;
@ -88,6 +90,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{
const processNameParam = useParams().processName;
const processName = process === null ? processNameParam : process.name;
let tableVariantLocalStorageKey: string | null = null;
if(table)
{
tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${table.name}`;
}
///////////////////
// process state //
@ -221,13 +228,14 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const download = (url: string, fileName: string) =>
{
const qController = Client.getInstance();
/////////////////////////////////////////////////////////////////////////////////////////////
// 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();
xhr.open("POST", url);
xhr.responseType = "blob";
let formData = new FormData();
formData.append("Authorization", qController.getAuthorizationHeaderValue());
// @ts-ignore
xhr.send(formData);
@ -315,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
@ -368,15 +376,15 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
// but our first use case, they're the same, so... this needs fixed. //
// they also need to know the 'otherValues' in this process - e.g., for filtering //
////////////////////////////////////////////////////////////////////////////////////
if(formFields && processValues)
if (formFields && processValues)
{
Object.keys(formFields).forEach((key) =>
{
if(formFields[key].possibleValueProps)
if (formFields[key].possibleValueProps)
{
if(processValues[key])
if (processValues[key])
{
formFields[key].possibleValueProps.initialDisplayValue = processValues[key]
formFields[key].possibleValueProps.initialDisplayValue = processValues[key];
}
formFields[key].possibleValueProps.otherValues = formFields[key].possibleValueProps.otherValues ?? new Map<string, any>();
@ -385,7 +393,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
formFields[key].possibleValueProps.otherValues.set(otherKey, processValues[otherKey]);
});
}
})
});
}
return (
@ -741,7 +749,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
formValidations[fieldName] = validation;
};
if(tableMetaData)
if (tableMetaData)
{
console.log("Adding table name field... ?", tableMetaData.name);
addField("tableName", {type: "hidden", omitFromQDynamicForm: true}, tableMetaData.name, null);
@ -794,15 +802,15 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////////////////////////
Object.keys(dynamicFormFields).forEach((key: any) =>
{
if(dynamicFormFields[key].possibleValueProps)
if (dynamicFormFields[key].possibleValueProps)
{
dynamicFormFields[key].possibleValueProps.otherValues = dynamicFormFields[key].possibleValueProps.otherValues ?? new Map<string, any>();
Object.keys(initialValues).forEach((ivKey: any) =>
{
dynamicFormFields[key].possibleValueProps.otherValues.set(ivKey, initialValues[ivKey]);
})
});
}
})
});
////////////////////////////////////////////////////
// disable all fields if this is a bulk edit form //
@ -1075,8 +1083,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
let queryStringPairsForInit = [];
if (urlSearchParams.get("recordIds"))
{
const recordIdsFromQueryString = urlSearchParams.get("recordIds").split(",");
const encodedRecordIds = recordIdsFromQueryString.map(r => encodeURIComponent(r)).join(",");
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${urlSearchParams.get("recordIds")}`);
queryStringPairsForInit.push(`recordIds=${encodedRecordIds}`);
}
else if (urlSearchParams.get("filterJSON"))
{
@ -1090,16 +1100,23 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
// }
else if (recordIds)
{
if (typeof recordIds === "string")
{
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${recordIds}`);
}
else if (recordIds instanceof QQueryFilter)
if (recordIds instanceof QQueryFilter)
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${JSON.stringify(recordIds)}`);
}
else if (typeof recordIds === "object" && recordIds.length)
{
const encodedRecordIds = recordIds.map(r => encodeURIComponent(r)).join(",");
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${encodedRecordIds}`);
}
}
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
{
let tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
queryStringPairsForInit.push(`tableVariant=${JSON.stringify(tableVariant)}`);
}
try
@ -1147,6 +1164,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
}
}
if (tableMetaData)
{
queryStringPairsForInit.push(`tableName=${encodeURIComponent(tableMetaData.name)}`);
}
try
{
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&"));
@ -1182,6 +1204,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
formData.append(key, values[key]);
});
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
{
let tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
formData.append("tableVariant", JSON.stringify(tableVariant));
}
if (doesStepHaveComponent(activeStep, QComponentType.BULK_EDIT_FORM))
{
const bulkEditEnabledFields: string[] = [];
@ -1250,6 +1278,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

@ -82,7 +82,8 @@ const ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT = "qqq.rowsPerPage";
const PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT = "qqq.pinnedColumns";
const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables";
const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density";
const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
interface Props
{
@ -233,7 +234,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
const [launchingProcess, setLaunchingProcess] = useState(launchProcess);
const [recordIdsForProcess, setRecordIdsForProcess] = useState(null as string | QQueryFilter);
const [recordIdsForProcess, setRecordIdsForProcess] = useState([] as string[] | QQueryFilter);
const [columnStatsFieldName, setColumnStatsFieldName] = useState(null as string);
const [columnStatsField, setColumnStatsField] = useState(null as QFieldMetaData);
const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string)
@ -538,15 +539,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<CustomWidthTooltip title={tooltipHTML}>
<IconButton sx={{p: 0, fontSize: "0.5rem", mb: 1, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">emergency</Icon></IconButton>
</CustomWidthTooltip>
{
tableVariant &&
<Typography variant="h6" color="text" fontWeight="light">
{tableMetaData.variantTableLabel}: {tableVariant.name}
<Tooltip title={`Change ${tableMetaData.variantTableLabel}`}>
<IconButton onClick={promptForTableVariantSelection} sx={{p: 0, m: 0, ml: .5, mb: .5, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">settings</Icon></IconButton>
</Tooltip>
</Typography>
}
{tableVariant && getTableVariantHeader()}
</div>);
}
else
@ -554,19 +547,23 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return (
<div>
{label}
{
tableVariant &&
<Typography variant="h6" color="text" fontWeight="light">
{tableMetaData.variantTableLabel}: {tableVariant.name}
<Tooltip title={`Change ${tableMetaData.variantTableLabel}`}>
<IconButton onClick={promptForTableVariantSelection} sx={{p: 0, m: 0, ml: .5, mb: .5, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">settings</Icon></IconButton>
</Tooltip>
</Typography>
}
{tableVariant && getTableVariantHeader()}
</div>);
}
};
const getTableVariantHeader = () =>
{
return (
<Typography variant="h6" color="text" fontWeight="light">
{tableMetaData?.variantTableLabel}: {tableVariant?.name}
<Tooltip title={`Change ${tableMetaData?.variantTableLabel}`}>
<IconButton onClick={promptForTableVariantSelection} sx={{p: 0, m: 0, ml: .5, mb: .5, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">settings</Icon></IconButton>
</Tooltip>
</Typography>
);
}
const updateTable = () =>
{
setLoading(true);
@ -925,11 +922,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
if (table.primaryKeyField !== "id")
{
navigate(`${metaData.getTablePathByName(tableName)}/${params.row[tableMetaData.primaryKeyField]}`);
navigate(`${metaData.getTablePathByName(tableName)}/${encodeURIComponent(params.row[tableMetaData.primaryKeyField])}`);
}
else
{
navigate(`${metaData.getTablePathByName(tableName)}/${params.id}`);
navigate(`${metaData.getTablePathByName(tableName)}/${encodeURIComponent(params.id)}`);
}
}, 100);
}
@ -1139,7 +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}" >
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">
</form>
@ -1188,17 +1184,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
if (selectFullFilterState === "filter")
{
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel))}`;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}`;
}
if (selectFullFilterState === "filterSubset")
{
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel, selectionSubsetSize))}`;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)))}`;
}
if (selectedIds.length > 0)
{
return `?recordsParam=recordIds&recordIds=${selectedIds.join(",")}`;
return `?recordsParam=recordIds&recordIds=${selectedIds.map(r => encodeURIComponent(r)).join(",")}`;
}
return "";
@ -1216,11 +1212,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}
else if (selectedIds.length > 0)
{
setRecordIdsForProcess(selectedIds.join(","));
setRecordIdsForProcess(selectedIds);
}
else
{
setRecordIdsForProcess("");
setRecordIdsForProcess([]);
}
navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}${getRecordsQueryString()}`);
@ -1887,9 +1883,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////////////////
if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_QUERY) && tableMetaData.capabilities.has(Capability.TABLE_GET))
{
if(tableMetaData?.usesVariants && (!tableVariant || tableVariantPromptOpen))
{
return (
<BaseLayout>
<TableVariantDialog table={tableMetaData} isOpen={true} closeHandler={(value: QTableVariant) =>
{
setTableVariantPromptOpen(false);
setTableVariant(value);
}} />
</BaseLayout>
);
}
return (
<BaseLayout>
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} autoOpen={true} buttonVisible={false} mayClose={false} />
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} tableVariant={tableVariant} autoOpen={true} buttonVisible={false} mayClose={false} subHeader={
<Box mb={2}>
{getTableVariantHeader()}
</Box>
} />
</BaseLayout>
);
}
@ -2004,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}
@ -2028,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

@ -193,7 +193,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{
document.removeEventListener("keydown", down)
}
}, [dotMenuOpen, keyboardHelpOpen, showEditChildForm, showAudit, metaData])
}, [dotMenuOpen, keyboardHelpOpen, showEditChildForm, showAudit, metaData, location])
const gotoCreate = () =>
{
@ -931,7 +931,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
activeModalProcess &&
<Modal open={activeModalProcess !== null} onClose={(event, reason) => closeModalProcess(event, reason)}>
<div className="modalProcess">
<ProcessRun process={activeModalProcess} isModal={true} table={tableMetaData} recordIds={id} closeModalHandler={closeModalProcess} />
<ProcessRun process={activeModalProcess} isModal={true} table={tableMetaData} recordIds={[id]} closeModalHandler={closeModalProcess} />
</div>
</Modal>
}

View File

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

View File

@ -246,7 +246,7 @@ export default class DataGridUtils
if (key === tableMetaData.primaryKeyField && linkBase)
{
column.renderCell = (cellValues: any) => (
<Link to={`${linkBase}${cellValues.value}`} onClick={(e) => e.stopPropagation()}>{cellValues.value}</Link>
<Link to={`${linkBase}${encodeURIComponent(cellValues.value)}`} onClick={(e) => e.stopPropagation()}>{cellValues.value}</Link>
);
}
});

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,13 +99,6 @@ export default class HtmlUtils
form.setAttribute("target", "downloadIframe");
iframe.appendChild(form);
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");
@ -113,11 +110,17 @@ 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:"))
{
/////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////
const openInWindow = window.open("", "_blank");
openInWindow.document.write(`<html lang="en">
<body style="margin: 0">
@ -144,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

@ -49,6 +49,7 @@ module.exports = function (app)
app.use("/data/*", getRequestHandler());
app.use("/widget/*", getRequestHandler());
app.use("/serverInfo", getRequestHandler());
app.use("/manageSession", getRequestHandler());
app.use("/processes", getRequestHandler());
app.use("/reports", getRequestHandler());
app.use("/images", getRequestHandler());

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;
/*******************************************************************************
@ -18,7 +24,7 @@ import org.openqa.selenium.chrome.ChromeOptions;
*******************************************************************************/
public class QBaseSeleniumTest
{
private static ChromeOptions chromeOptions;
protected static ChromeOptions chromeOptions;
protected WebDriver driver;
protected QSeleniumJavalin qSeleniumJavalin;
@ -52,15 +58,88 @@ public class QBaseSeleniumTest
**
*******************************************************************************/
@BeforeEach
void 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);
qSeleniumJavalin = new QSeleniumJavalin();
addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.start();
if(useInternalJavalin())
{
qSeleniumJavalin = new QSeleniumJavalin();
addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.start();
}
}
/*******************************************************************************
**
*******************************************************************************/
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.
*******************************************************************************/
protected boolean useInternalJavalin()
{
return (true);
}
@ -75,6 +154,8 @@ public class QBaseSeleniumTest
.withRouteToFile("/metaData/authentication", "metaData/authentication.json")
.withRouteToFile("/metaData/table/person", "metaData/table/person.json")
.withRouteToFile("/metaData/table/city", "metaData/table/person.json")
.withRouteToFile("/metaData/table/script", "metaData/table/script.json")
.withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
}

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;
@ -5,11 +26,16 @@ import java.io.File;
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;
@ -35,6 +61,8 @@ public class QSeleniumLib
private boolean SCREENSHOTS_ENABLED = true;
private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/";
private boolean autoHighlight = false;
/*******************************************************************************
@ -96,6 +124,17 @@ public class QSeleniumLib
/*******************************************************************************
** Getter for BASE_URL
**
*******************************************************************************/
public String getBaseUrl()
{
return BASE_URL;
}
/*******************************************************************************
**
*******************************************************************************/
@ -175,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;
}
@ -218,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;
@ -244,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;
@ -265,6 +310,31 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
public void waitForNumberOfWindowsToBe(int number)
{
LOG.debug("Waiting for number of windows (tabs) to be [" + number + "]");
long start = System.currentTimeMillis();
do
{
if(driver.getWindowHandles().size() == number)
{
LOG.debug("Number of windows (tabs) is [" + number + "]");
return;
}
sleepABit();
}
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
fail("Failed waiting for number of windows (tabs) to be [" + number + "] after [" + WAIT_SECONDS + "] seconds.");
}
/*******************************************************************************
**
*******************************************************************************/
@ -293,10 +363,76 @@ 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);
});
}
/*******************************************************************************
**
*******************************************************************************/
public void switchToSecondaryTab()
{
String originalWindow = driver.getWindowHandle();
waitForNumberOfWindowsToBe(2);
Set<String> windowHandles = driver.getWindowHandles();
for(String windowHandle : windowHandles)
{
if(!windowHandle.equals(originalWindow))
{
driver.switchTo().window(windowHandle);
return;
}
}
fail("Failed to find a window handle not equal to the original window handle. Original=[" + originalWindow + "]. All=[" + windowHandles + "]");
}
/*******************************************************************************
**
*******************************************************************************/
public void closeSecondaryTab()
{
String originalWindow = driver.getWindowHandle();
driver.close();
Set<String> windowHandles = driver.getWindowHandles();
for(String windowHandle : windowHandles)
{
if(!windowHandle.equals(originalWindow))
{
driver.switchTo().window(windowHandle);
return;
}
}
fail("Failed to find a window handle not equal to the original window handle. Original=[" + originalWindow + "]. All=[" + windowHandles + "]");
}
@FunctionalInterface
public interface Code<T>
{
public T run();
/*******************************************************************************
**
*******************************************************************************/
T run();
}
@ -346,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);
}
}
@ -353,6 +490,10 @@ public class QSeleniumLib
{
LOG.debug("Caught a StaleElementReferenceException - will retry.");
}
catch(NoSuchElementException nsee)
{
LOG.debug("Caught a NoSuchElementException - will retry.");
}
}
sleepABit();
@ -365,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.
@ -394,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);
@ -471,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

@ -0,0 +1,65 @@
/*
* 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 com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Test that goes to a record, clicks a link for another record, then
** hits 'e' on keyboard to edit the second record - and confirms that we're
** on the edit url for the second record, not the first (a former bug).
*******************************************************************************/
public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/data/script/1", "data/script/1.json");
qSeleniumJavalin.withRouteToFile("/data/scriptRevision/100", "data/scriptRevision/100.json");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testClickLinkOnRecordThenEditShortcutTest()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script");
qSeleniumLib.waitForSelectorContaining("A", "100").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").sendKeys("e");
assertTrue(qSeleniumLib.driver.getCurrentUrl().endsWith("/scriptRevision/100/edit"));
}
}

View File

@ -0,0 +1,111 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.materialdashboard.tests;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Tests for dashboard table widget with export button
*******************************************************************************/
public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/city/count", "data/city/count.json");
qSeleniumJavalin.withRouteToString("/widget/SampleTableWidget", """
{
"label": "Sample Table Widget",
"footerHTML": "<span class='material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit' aria-hidden='true'><span class='dashboard-schedule-icon'>schedule</span></span>Updated at 2023-10-17 09:11:38 AM CDT",
"columns": [
{ "type": "html", "header": "Id", "accessor": "id", "width": "1%" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "99%" }
],
"rows": [
{ "id": "1", "name": "<a href='/setup/person/1'>Homer S.</a>" },
{ "id": "2", "name": "<a href='/setup/person/2'>Marge B.</a>" },
{ "id": "3", "name": "<a href='/setup/person/3'>Bart J.</a>" }
],
"type": "table"
}
""");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDashboardTableWidgetExport() throws IOException
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App");
////////////////////////////////////////////////////////////////////////
// assert that the table widget rendered its header and some contents //
////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget h6", "Sample Table Widget");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget table a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget div", "Updated at 2023-10-17 09:11:38 AM CDT");
/////////////////////////////
// click the export button //
/////////////////////////////
qSeleniumLib.waitForSelector("#SampleTableWidget h6")
.findElement(By.xpath("./.."))
.findElement(By.cssSelector("button"))
.click();
qSeleniumLib.waitForCondition("Should have downloaded 1 file", () -> getDownloadedFiles().size() == 1);
File csvFile = getDownloadedFiles().get(0);
assertThat(csvFile.getName()).matches("Sample Table Widget.*.csv");
String fileContents = FileUtils.readFileToString(csvFile, StandardCharsets.UTF_8);
assertEquals("""
"Id","Name"
"1","Homer S."
"2","Marge B."
"3","Bart J."
""", fileContents);
// qSeleniumLib.waitForever();
}
}

View File

@ -64,8 +64,6 @@ public class ScriptTableTest extends QBaseSeleniumTest
qSeleniumLib.waitForSelectorContaining("DIV.ace_line", "var hello;");
qSeleniumLib.waitForSelectorContaining("DIV", "2nd commit");
qSeleniumLib.waitForSelectorContaining("DIV", "Initial checkin");
qSeleniumLib.waitForever();
}
}

View File

@ -1,14 +1,22 @@
{
"tableName": "scriptRevision",
"recordLabel": "Hello, Script Revision",
"values": {
"id": 100,
"id": "100",
"name": "Hello, Script Revision",
"sequenceNo": "22",
"commitMessage": "Initial checkin",
"author": "Jon Programmer",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
},
"displayValues": {
"id": "1",
"name": "Hello, Script Revision",
"scriptId": "1",
"sequenceNo": "22",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
},
"associatedRecords": {
"files": [
@ -25,4 +33,4 @@
}
]
}
}
}

View File

@ -131,7 +131,8 @@
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY"
"TABLE_QUERY",
"TABLE_UPDATE"
],
"readPermission": true,
"insertPermission": true,
@ -301,8 +302,7 @@
"label": "Greetings App",
"iconName": "emoji_people",
"widgets": [
"PersonsByCreateDateBarChart",
"QuickSightChartRenderer"
"SampleTableWidget"
],
"children": [
{
@ -737,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",

View File

@ -0,0 +1,152 @@
{
"table": {
"name": "scriptRevision",
"label": "Script Revision",
"isHidden": false,
"primaryKeyField": "id",
"iconName": "history_edu",
"fields": {
"scriptId": {
"name": "scriptId",
"label": "Script",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"possibleValueSourceName": "script",
"displayFormat": "%s",
"adornments": [
{
"type": "SIZE",
"values": {
"width": "large"
}
},
{
"type": "LINK",
"values": {
"toRecordFromTable": "script"
}
}
]
},
"apiName": {
"name": "apiName",
"label": "API Name",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"possibleValueSourceName": "apiName",
"displayFormat": "%s"
},
"sequenceNo": {
"name": "sequenceNo",
"label": "Sequence No",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"apiVersion": {
"name": "apiVersion",
"label": "API Version",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"possibleValueSourceName": "apiVersion",
"displayFormat": "%s"
},
"commitMessage": {
"name": "commitMessage",
"label": "Commit Message",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"modifyDate": {
"name": "modifyDate",
"label": "Modify Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"author": {
"name": "author",
"label": "Author",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"id": {
"name": "id",
"label": "Id",
"type": "INTEGER",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"createDate": {
"name": "createDate",
"label": "Create Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
}
},
"sections": [
{
"name": "identity",
"label": "Identity",
"tier": "T1",
"fieldNames": [
"id",
"scriptId",
"sequenceNo"
],
"icon": {
"name": "badge"
},
"isHidden": false
},
{
"name": "dates",
"label": "Dates",
"tier": "T3",
"fieldNames": [
"createDate",
"modifyDate"
],
"icon": {
"name": "calendar_month"
},
"isHidden": false
}
],
"exposedJoins": [],
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"TABLE_INSERT",
"TABLE_UPDATE",
"QUERY_STATS"
],
"readPermission": true,
"insertPermission": true,
"editPermission": true,
"deletePermission": true,
"usesVariants": false
}
}