Compare commits

...

53 Commits

Author SHA1 Message Date
f6286ba6b7 Merge branch 'feature/CE-1107-add-day-by-day-views-for-the' into integration/sprint-40 2024-04-17 23:09:17 -05:00
9b0d135dc1 Merge branch 'main' into feature/CE-1107-add-day-by-day-views-for-the 2024-04-17 20:54:19 -05:00
3edb9cca6a Merged feature/CE-881-create-basic-saved-reports into integration/sprint-40 2024-04-17 10:52:31 -05:00
a84b0a0243 CE-1115 Pass a filter-for-backend version of the filter to the create-report screen 2024-04-17 10:52:12 -05:00
5f13e244d6 Merge pull request #55 from Kingsrook/feature/CE-1123-expose-access-lo-data-in
Feature/ce 1123 expose access lo data in
2024-04-17 09:34:48 -05:00
6faca42b3b CE-1115 Don't allow the same field to be used as both a row or column and a value. our generated xlsx pivot's don't support that (and i'm not sure it's a valid use-case anyway) 2024-04-17 08:54:22 -05:00
895791d9c9 Merged feature/CE-881-create-basic-saved-reports into integration/sprint-40 2024-04-16 20:39:49 -05:00
cb3162f084 CE-1115 Add sort indication to report setup; make recordQuery, when being used for report setup, not write local storage; 2024-04-16 20:39:33 -05:00
d7f2ff9fc4 Merged feature/CE-1123-expose-access-lo-data-in into integration/sprint-40 2024-04-16 18:40:57 -05:00
da57226fe5 CE-1123 - Update google analytics to work with events as well as page views; add calls to it to most actual pages. 2024-04-16 16:33:27 -05:00
e05b5b247f Update toalways deploy on integration branches, same as main 2024-04-16 11:40:37 -05:00
74c85201ec Merged feature/CE-881-create-basic-saved-reports into integration/sprint-40 2024-04-16 11:40:19 -05:00
73c907a3e1 CE-1115 Fix so that record query popup actually shows the filter & columns that are on the report edit screen... fix filters saved w/ record to be prep'ed for backend; refactor that prepForBackend method out of RecordQuery, into FilterUtils; update recordQuery to be a better manager of counts (showing when counting after the initial load, plus not always re-counting (e.g., when paginating) 2024-04-16 11:36:46 -05:00
dac0a24ec7 Update toalways deploy on integration branches, same as main 2024-04-16 07:57:17 -05:00
6accca5fec Merge branch 'feature/CE-1107-add-day-by-day-views-for-the' into integration/sprint-40 2024-04-15 21:38:16 -05:00
bcade32ed1 CE-1107: style updates to icon 2024-04-15 21:37:46 -05:00
13235c8aac Merged feature/CE-1123-expose-access-lo-data-in into integration/sprint-40 2024-04-15 19:26:48 -05:00
e3cbf9414b CE-1123 Initial checkin 2024-04-15 14:51:03 -05:00
6282723ff6 CE-1123 Update qfc to 1.0.96; add react-ga4 2024-04-15 12:54:52 -05:00
68d3119c6a CE-1123 store values in localStorage from backend from manageSession call; add googleAnalytics utils & function to recordAnalytics 2024-04-15 12:54:52 -05:00
731eab7136 CE-1123 update exception status to be number (for qfc change) 2024-04-15 12:52:59 -05:00
3b3834c032 Merge branch 'feature/CE-1107-add-day-by-day-views-for-the' into integration/sprint-40 2024-04-15 11:41:17 -05:00
4339f74c07 Do not trigger commands if controlKey is down (to help windows users) 2024-04-15 10:41:45 -05:00
8071c54ccd CE-1107: updates to date picker styles 2024-04-15 09:01:31 -05:00
82ffcfd659 Merged feature/CE-881-create-basic-saved-reports into integration/sprint-40 2024-04-15 08:52:14 -05:00
48e3eeabd4 CE-1115 fix availableFieldNames addition 2024-04-15 08:47:25 -05:00
04932030df Increase @kingsrook/qqq-frontend-core to 1.0.94 2024-04-14 20:11:23 -05:00
eafd8d98cd CE-1115 pre-QA commit on saved report UI, including:
- redo pivots so editing is in a modal
- add form validations
- field rules for clearing one field when another changes
2024-04-14 20:10:29 -05:00
2587e9ee68 Merge branch 'feature/CE-1107-add-day-by-day-views-for-the' into integration/sprint-40 2024-04-12 15:00:46 -05:00
334871988b CE-1107: added alert widget, fixed axios problems 2024-04-12 14:47:37 -05:00
2c0725852e CE-1115 change exception status to numbers per qfc, axios update 2024-04-11 10:59:37 -05:00
53c3e4d078 CE-1115 change exception status to numbers per qfc, axios update 2024-04-11 10:49:14 -05:00
5e0e4c37bb CE-1115 Update qfc (rebuilt to include latest dependabot updates) 2024-04-11 10:25:12 -05:00
cb7fa641eb CE-1115 checkpoint on report & pivotTable setup widgets:
- refactor into sub-components
- working drag & drop
- more help content
- disable things rather than alert if no table
2024-04-11 10:11:43 -05:00
cdec98afd8 CE-1115 - Updated qfc (widget help content multi-role); add react dnd 2024-04-11 10:09:54 -05:00
7e2a46b362 CE-1115 - take optional array of availableFieldNames (e.g., to only show a sub-set) 2024-04-11 10:07:59 -05:00
803725b8f1 CE-1115 - initial prototype of field-rules - e.g., clear one field when another changes 2024-04-11 10:07:41 -05:00
6b8049d4ce Remove dupe line from half-commit of a WIP change 2024-04-09 16:11:46 -05:00
d5381e23bf CE-1115 - add HeaderLinkButton and HeaderToggleComponent, and start doing right-additional things as components (despite backward naming!) 2024-04-09 16:06:25 -05:00
87ffd821f8 CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen 2024-04-09 16:04:50 -05:00
703868a725 CE-1115 - Initial working version 2024-04-09 16:04:35 -05:00
dee4b91a96 CE-1115 - Pass record into DashboardWidgets 2024-04-09 16:03:37 -05:00
f47924787a CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen 2024-04-09 16:03:15 -05:00
37b854baf0 CE-1115 - export interface Column 2024-04-09 16:00:24 -05:00
fb2e392dcb CE-1115 - Initial working version 2024-04-09 16:00:09 -05:00
034264eaa1 CE-1115 - Add options to control appearance; make hiddenFields ignore the selected field; 2024-04-09 15:59:23 -05:00
3558a91e7b CE-1115 - support having the ReportSetupWidget and PivotTableSetupWidget actually edit the form values used on the page 2024-04-09 15:58:19 -05:00
e5e49a6db8 CE-1115 - Add ReportSetupWidget and PivotTableSetupWidget 2024-04-09 15:54:12 -05:00
4c9c9ab80e CE-1115 - Break this component out into its own ... component. 2024-04-09 15:51:42 -05:00
0b77c8172c Merged feature/CE-1123-expose-access-log-data-in into integration/sprint-40 2024-04-08 08:53:05 -05:00
743137dc60 Try google tag js in index.html 2024-04-08 08:52:50 -05:00
ddb055bc81 Merge pull request #53 from Kingsrook/feature/CE-1072-update-how-we-display-imported
CE-1072 return displayValue for DATE_TIME fields (if they're differen…
2024-04-05 11:30:07 -05:00
66ddf4cb57 CE-1072 return displayValue for DATE_TIME fields (if they're different from the raw value) 2024-04-04 20:06:00 -05:00
44 changed files with 5101 additions and 1471 deletions

View File

@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ] context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters: filters:
branches: branches:
ignore: /main/ ignore: /(main|integration.*)/
tags: tags:
ignore: /(version|snapshot)-.*/ ignore: /(version|snapshot)-.*/
deploy: deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ] context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters: filters:
branches: branches:
only: /main/ only: /(main|integration.*)/
tags: tags:
only: /(version|snapshot)-.*/ only: /(version|snapshot)-.*/

2200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,14 @@
"@auth0/auth0-react": "1.10.2", "@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1", "@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0", "@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.90", "@kingsrook/qqq-frontend-core": "1.0.96",
"@mui/icons-material": "5.4.1", "@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1", "@mui/material": "5.11.1",
"@mui/styles": "5.11.1", "@mui/styles": "5.11.1",
"@mui/system": "5.11.1", "@mui/system": "5.11.1",
"@mui/x-data-grid": "5.17.23", "@mui/x-data-grid": "5.17.23",
"@mui/x-data-grid-pro": "5.17.23", "@mui/x-data-grid-pro": "5.17.23",
"@mui/x-date-pickers": "7.1.1",
"@mui/x-license-pro": "5.12.3", "@mui/x-license-pro": "5.12.3",
"@react-jvectormap/core": "1.0.1", "@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1", "@react-jvectormap/unitedstates": "1.0.1",
@ -26,6 +27,7 @@
"chroma-js": "2.4.2", "chroma-js": "2.4.2",
"cmdk": "0.2.0", "cmdk": "0.2.0",
"datejs": "1.0.0-rc3", "datejs": "1.0.0-rc3",
"dayjs": "1.11.10",
"downshift": "3.2.10", "downshift": "3.2.10",
"faker": "5.5.3", "faker": "5.5.3",
"form-data": "4.0.0", "form-data": "4.0.0",
@ -39,7 +41,10 @@
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-chartjs-2": "3.0.4", "react-chartjs-2": "3.0.4",
"react-cookie": "4.1.1", "react-cookie": "4.1.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.0.0", "react-dom": "18.0.0",
"react-ga4": "2.1.0",
"react-github-btn": "1.2.1", "react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0", "react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1", "react-markdown": "9.0.1",

View File

@ -25,6 +25,15 @@ Coded by www.creative-tim.com
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet" />
</head> </head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1NV0P06WS"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-X1NV0P06WS');
</script>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>

View File

@ -33,12 +33,8 @@ import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles"; import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro"; import {LicenseInfo} from "@mui/x-license-pro";
import jwt_decode from "jwt-decode";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
import CommandMenu from "CommandMenu"; import CommandMenu from "CommandMenu";
import jwt_decode from "jwt-decode";
import QContext from "QContext"; import QContext from "QContext";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme"; import theme from "qqq/components/legacy/Theme";
@ -53,8 +49,13 @@ import EntityEdit from "qqq/pages/records/edit/RecordEdit";
import RecordQuery from "qqq/pages/records/query/RecordQuery"; import RecordQuery from "qqq/pages/records/query/RecordQuery";
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView"; import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
import RecordView from "qqq/pages/records/view/RecordView"; import RecordView from "qqq/pages/records/view/RecordView";
import GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance(); const qController = Client.getInstance();
@ -79,7 +80,7 @@ export default function App()
Client.setUnauthorizedCallback(() => Client.setUnauthorizedCallback(() =>
{ {
logout(); logout();
}) });
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean => const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{ {
@ -104,7 +105,7 @@ export default function App()
// if the old (local storage) token is expired, then we need to store the new one // // if the old (local storage) token is expired, then we need to store the new one //
//////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"]; const oldExp = oldJSON["exp"];
if(oldExp * 1000 < (new Date().getTime())) if (oldExp * 1000 < (new Date().getTime()))
{ {
console.log("Access token in local storage was expired - so we should store a new one."); console.log("Access token in local storage was expired - so we should store a new one.");
return (true); return (true);
@ -114,21 +115,21 @@ export default function App()
// remove the exp & iat values from what we compare - as they are always different from auth0 // // 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. // // note, this is only deleting them from what we compare, not from what we'd store. //
//////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"] delete newJSON["exp"];
delete newJSON["iat"] delete newJSON["iat"];
delete oldJSON["exp"] delete oldJSON["exp"];
delete oldJSON["iat"] delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON); const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if(different) if (different)
{ {
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one."); console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
} }
return (different); return (different);
} }
catch(e) catch (e)
{ {
console.log("Caught in shouldStoreNewToken: " + e) console.log("Caught in shouldStoreNewToken: " + e);
} }
return (true); return (true);
@ -160,7 +161,7 @@ export default function App()
if (shouldStoreNewToken(accessToken, lsAccessToken)) if (shouldStoreNewToken(accessToken, lsAccessToken))
{ {
console.log("Sending accessToken to backend, requesting a sessionUUID..."); console.log("Sending accessToken to backend, requesting a sessionUUID...");
const newSessionUuid = await qController.manageSession(accessToken, null); const {uuid: newSessionUuid, values} = 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. // // the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
@ -168,6 +169,7 @@ export default function App()
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"}); // setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken); localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken"); console.log("Got new sessionUUID from backend, and stored new accessToken");
} }
else else
@ -185,7 +187,7 @@ export default function App()
{ {
console.log(`Error loading token: ${JSON.stringify(e)}`); console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage(); qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken") localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout(); logout();
return; return;
@ -550,7 +552,7 @@ export default function App()
}); });
} }
const pathToLabelMap: {[path: string]: string} = {} 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]; const route = appRoutesList[i];
@ -575,11 +577,11 @@ export default function App()
console.error(e); console.error(e);
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "401") if ((e as QException).status === 401)
{ {
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies"); console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage(); qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken") localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
@ -656,13 +658,25 @@ export default function App()
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element); const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
const [accentColor, setAccentColor] = useState("#0062FF"); const [accentColor, setAccentColor] = useState("#0062FF");
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7") const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
const [tableMetaData, setTableMetaData] = useState(null); const [tableMetaData, setTableMetaData] = useState(null);
const [tableProcesses, setTableProcesses] = useState(null); const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false); const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false); const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp")); const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************
**
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model)
}
return ( return (
appRoutes && ( appRoutes && (
@ -682,6 +696,7 @@ export default function App()
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses), setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent), setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),
setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen), setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen),
recordAnalytics: recordAnalytics,
pathToLabelMap: pathToLabelMap, pathToLabelMap: pathToLabelMap,
branding: branding branding: branding
}}> }}>
@ -707,4 +722,4 @@ export default function App()
</QContext.Provider> </QContext.Provider>
) )
); );
} }

View File

@ -22,6 +22,7 @@
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData"; import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import {createContext} from "react"; import {createContext} from "react";
interface QContext interface QContext
@ -47,6 +48,11 @@ interface QContext
tableProcesses?: QProcessMetaData[]; tableProcesses?: QProcessMetaData[];
setTableProcesses?: (tableProcesses: QProcessMetaData[]) => void; setTableProcesses?: (tableProcesses: QProcessMetaData[]) => void;
///////////////////////////////////////////
// function to record an analytics event //
///////////////////////////////////////////
recordAnalytics?: (model: AnalyticsModel) => void;
/////////////////////////////////// ///////////////////////////////////
// constants - no setters needed // // constants - no setters needed //
/////////////////////////////////// ///////////////////////////////////

View File

@ -22,6 +22,7 @@
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -30,6 +31,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
/******************************************************************************* /*******************************************************************************
@ -37,8 +40,11 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
*******************************************************************************/ *******************************************************************************/
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{ {
public static final String TYPE = "materialDashboard";
private List<List<String>> gotoFieldNames; private List<List<String>> gotoFieldNames;
private List<String> defaultQuickFilterFieldNames; private List<String> defaultQuickFilterFieldNames;
private List<FieldRule> fieldRules;
/******************************************************************************* /*******************************************************************************
@ -58,10 +64,25 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
@Override @Override
public String getType() public String getType()
{ {
return ("materialDashboard"); return (TYPE);
} }
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardTableMetaData ofOrWithNew(QTableMetaData table)
{
MaterialDashboardTableMetaData supplementalMetaData = (MaterialDashboardTableMetaData) table.getSupplementalMetaData(TYPE);
if(supplementalMetaData == null)
{
supplementalMetaData = new MaterialDashboardTableMetaData();
table.withSupplementalMetaData(supplementalMetaData);
}
return (supplementalMetaData);
}
/******************************************************************************* /*******************************************************************************
** Getter for gotoFieldNames ** Getter for gotoFieldNames
@ -110,6 +131,22 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: "); validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: ");
} }
validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: "); validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: ");
for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules))
{
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
}
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getTargetField()), prefix + "has a fieldRule without a targetField"))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
}
}
} }
@ -124,7 +161,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{ {
if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName)) if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName))
{ {
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName); qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + "has a duplicated field name: " + fieldName);
usedNames.add(fieldName); usedNames.add(fieldName);
} }
} }
@ -161,4 +198,51 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
return (this); return (this);
} }
/*******************************************************************************
** Getter for fieldRules
*******************************************************************************/
public List<FieldRule> getFieldRules()
{
return (this.fieldRules);
}
/*******************************************************************************
** Setter for fieldRules
*******************************************************************************/
public void setFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
return (this);
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRule(FieldRule fieldRule)
{
if(this.fieldRules == null)
{
this.fieldRules = new ArrayList<>();
}
this.fieldRules.add(fieldRule);
return (this);
}
} }

View File

@ -0,0 +1,165 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
import java.io.Serializable;
/*******************************************************************************
** definition of rules for how UI fields should behave.
**
** e.g., one field being changed causing different things to be needed in another
** field.
*******************************************************************************/
public class FieldRule implements Serializable
{
private FieldRuleTrigger trigger;
private String sourceField;
private FieldRuleAction action;
private String targetField;
/*******************************************************************************
** Getter for trigger
*******************************************************************************/
public FieldRuleTrigger getTrigger()
{
return (this.trigger);
}
/*******************************************************************************
** Setter for trigger
*******************************************************************************/
public void setTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
}
/*******************************************************************************
** Fluent setter for trigger
*******************************************************************************/
public FieldRule withTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
return (this);
}
/*******************************************************************************
** Getter for sourceField
*******************************************************************************/
public String getSourceField()
{
return (this.sourceField);
}
/*******************************************************************************
** Setter for sourceField
*******************************************************************************/
public void setSourceField(String sourceField)
{
this.sourceField = sourceField;
}
/*******************************************************************************
** Fluent setter for sourceField
*******************************************************************************/
public FieldRule withSourceField(String sourceField)
{
this.sourceField = sourceField;
return (this);
}
/*******************************************************************************
** Getter for action
*******************************************************************************/
public FieldRuleAction getAction()
{
return (this.action);
}
/*******************************************************************************
** Setter for action
*******************************************************************************/
public void setAction(FieldRuleAction action)
{
this.action = action;
}
/*******************************************************************************
** Fluent setter for action
*******************************************************************************/
public FieldRule withAction(FieldRuleAction action)
{
this.action = action;
return (this);
}
/*******************************************************************************
** Getter for targetField
*******************************************************************************/
public String getTargetField()
{
return (this.targetField);
}
/*******************************************************************************
** Setter for targetField
*******************************************************************************/
public void setTargetField(String targetField)
{
this.targetField = targetField;
}
/*******************************************************************************
** Fluent setter for targetField
*******************************************************************************/
public FieldRule withTargetField(String targetField)
{
this.targetField = targetField;
return (this);
}
}

View File

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

View File

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

View File

@ -0,0 +1,59 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.savedreports;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardTableMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger;
/*******************************************************************************
** Add frontend material dashboard enhacements to saved report table
*******************************************************************************/
public class SavedReportTableFrontendMaterialDashboardEnricher
{
/*******************************************************************************
**
*******************************************************************************/
public static void enrich(QTableMetaData tableMetaData)
{
MaterialDashboardTableMetaData materialDashboardTableMetaData = MaterialDashboardTableMetaData.ofOrWithNew(tableMetaData);
/////////////////////////////////////////////////////////////////////////
// make changes to the tableName field clear the value in these fields //
/////////////////////////////////////////////////////////////////////////
for(String targetField : List.of("queryFilterJson", "columnsJson", "pivotTableJson"))
{
materialDashboardTableMetaData.withFieldRule(new FieldRule()
.withSourceField("tableName")
.withTrigger(FieldRuleTrigger.ON_CHANGE)
.withAction(FieldRuleAction.CLEAR_TARGET_FIELD)
.withTargetField(targetField));
}
}
}

View File

@ -34,10 +34,10 @@ import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import React, {useContext, useEffect, useState} from "react";
import QContext from "QContext"; import QContext from "QContext";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
interface Props interface Props
{ {
@ -217,7 +217,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
{ {
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "403") if ((e as QException).status === 403)
{ {
setStatusString("You do not have permission to view audits"); setStatusString("You do not have permission to view audits");
return; return;

View File

@ -43,7 +43,10 @@ import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent"; import HelpContent from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar"; import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"; import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
import {FieldRule, FieldRuleAction, FieldRuleTrigger} from "qqq/models/fields/FieldRules";
import HtmlUtils from "qqq/utils/HtmlUtils"; import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
@ -77,12 +80,21 @@ EntityForm.defaultProps = {
onSubmitCallback: null, onSubmitCallback: null,
}; };
////////////////////////////////////////////////////////////////////////////
// define a function that we can make referenes to, which we'll overwrite //
// with formik's setFieldValue function, once we're inside formik. //
////////////////////////////////////////////////////////////////////////////
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
{
}
function EntityForm(props: Props): JSX.Element function EntityForm(props: Props): JSX.Element
{ {
const qController = Client.getInstance(); const qController = Client.getInstance();
const tableNameParam = useParams().tableName; const tableNameParam = useParams().tableName;
const tableName = props.table === null ? tableNameParam : props.table.name; const tableName = props.table === null ? tableNameParam : props.table.name;
const {accentColor} = useContext(QContext); const {accentColor, recordAnalytics} = useContext(QContext);
const [formTitle, setFormTitle] = useState(""); const [formTitle, setFormTitle] = useState("");
const [validations, setValidations] = useState({}); const [validations, setValidations] = useState({});
@ -97,6 +109,7 @@ function EntityForm(props: Props): JSX.Element
const [asyncLoadInited, setAsyncLoadInited] = useState(false); const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [fieldRules, setFieldRules] = useState([] as FieldRule[]);
const [metaData, setMetaData] = useState(null as QInstance); const [metaData, setMetaData] = useState(null as QInstance);
const [record, setRecord] = useState(null as QRecord); const [record, setRecord] = useState(null as QRecord);
const [tableSections, setTableSections] = useState(null as QTableSection[]); const [tableSections, setTableSections] = useState(null as QTableSection[]);
@ -108,6 +121,9 @@ function EntityForm(props: Props): JSX.Element
const [notAllowedError, setNotAllowedError] = useState(null as string); const [notAllowedError, setNotAllowedError] = useState(null as string);
const [formValuesJSON, setFormValuesJSON] = useState("");
const [formValues, setFormValues] = useState({} as {[name: string]: any});
const {pageHeader, setPageHeader} = useContext(QContext); const {pageHeader, setPageHeader} = useContext(QContext);
const navigate = useNavigate(); const navigate = useNavigate();
@ -269,6 +285,21 @@ function EntityForm(props: Props): JSX.Element
} }
/*******************************************************************************
** Watch the record values - if they change, re-render widgets
*******************************************************************************/
useEffect(() =>
{
const newRenderedWidgetSections: {[name: string]: JSX.Element} = {};
for (let widgetName in renderedWidgetSections)
{
const widgetMetaData = metaData.widgets.get(widgetName);
newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, childListWidgetData[widgetName]);
}
setRenderedWidgetSections(newRenderedWidgetSections);
}, [formValuesJSON]);
/******************************************************************************* /*******************************************************************************
** render a section (full of fields) as a form ** render a section (full of fields) as a form
*******************************************************************************/ *******************************************************************************/
@ -319,25 +350,66 @@ function EntityForm(props: Props): JSX.Element
} }
/*******************************************************************************
** if we have a widget that wants to set form-field values, they can take this
** function in as a callback, and then call it with their values.
*******************************************************************************/
function setFormFieldValuesFromWidget(values: {[name: string]: any})
{
for (let key in values)
{
formikSetFieldValueFunction(key, values[key]);
}
}
/******************************************************************************* /*******************************************************************************
** render a section as a widget ** render a section as a widget
*******************************************************************************/ *******************************************************************************/
function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element
{ {
widgetData.viewAllLink = null; if(widgetMetaData.type == "childRecordList")
widgetMetaData.showExportButton = false; {
widgetData.viewAllLink = null;
widgetMetaData.showExportButton = false;
return <RecordGridWidget return <RecordGridWidget
key={new Date().getTime()} // added so that editing values actually re-renders... key={new Date().getTime()} // added so that editing values actually re-renders...
widgetMetaData={widgetMetaData} widgetMetaData={widgetMetaData}
data={widgetData} data={widgetData}
disableRowClick disableRowClick
allowRecordEdit allowRecordEdit
allowRecordDelete allowRecordDelete
addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData)} addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)} editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)} deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)}
/>; />;
}
if(widgetMetaData.type == "reportSetup")
{
return <ReportSetupWidget
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
isEditable={true}
widgetMetaData={widgetMetaData}
recordValues={formValues}
onSaveCallback={setFormFieldValuesFromWidget}
/>
}
if(widgetMetaData.type == "pivotTableSetup")
{
return <PivotTableSetupWidget
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
isEditable={true}
widgetMetaData={widgetMetaData}
recordValues={formValues}
onSaveCallback={setFormFieldValuesFromWidget}
/>
}
return (<Box>Unsupported widget type: {widgetMetaData.type}</Box>)
} }
@ -357,6 +429,32 @@ function EntityForm(props: Props): JSX.Element
} }
/*******************************************************************************
**
*******************************************************************************/
function setupFieldRules(tableMetaData: QTableMetaData)
{
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if(!mdbMetaData)
{
return;
}
if(mdbMetaData.fieldRules)
{
const newFieldRules: FieldRule[] = [];
for (let i = 0; i < mdbMetaData.fieldRules.length; i++)
{
newFieldRules.push(mdbMetaData.fieldRules[i]);
}
setFieldRules(newFieldRules);
}
}
//////////////////
// initial load //
//////////////////
if (!asyncLoadInited) if (!asyncLoadInited)
{ {
setAsyncLoadInited(true); setAsyncLoadInited(true);
@ -364,6 +462,9 @@ function EntityForm(props: Props): JSX.Element
{ {
const tableMetaData = await qController.loadTableMetaData(tableName); const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label});
setupFieldRules(tableMetaData);
const metaData = await qController.loadMetaData(); const metaData = await qController.loadMetaData();
setMetaData(metaData); setMetaData(metaData);
@ -373,7 +474,21 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////// /////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) => const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
{ {
return section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList" && metaData.widgets.get(section.widgetName)?.defaultValues?.has("manageAssociationName"); const widget = metaData.widgets.get(section.widgetName);
if(widget)
{
if(widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
{
return (true);
}
if(widget.type == "reportSetup" || widget.type == "pivotTableSetup")
{
return (true);
}
}
return (false);
}); });
setTableSections(tableSections); setTableSections(tableSections);
@ -394,6 +509,7 @@ function EntityForm(props: Props): JSX.Element
{ {
record = await qController.get(tableName, props.id); record = await qController.get(tableName, props.id);
setRecord(record); setRecord(record);
recordAnalytics({category: "tableEvents", action: props.isCopy ? "copy" : "edit", label: tableMetaData?.label + " / " + record?.recordLabel});
const titleVerb = props.isCopy ? "Copy" : "Edit"; const titleVerb = props.isCopy ? "Copy" : "Edit";
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
@ -433,6 +549,7 @@ function EntityForm(props: Props): JSX.Element
// else handle preparing to do an insert // // else handle preparing to do an insert //
/////////////////////////////////////////// ///////////////////////////////////////////
setFormTitle(`Creating New ${tableMetaData?.label}`); setFormTitle(`Creating New ${tableMetaData?.label}`);
recordAnalytics({category: "tableEvents", action: "new", label: tableMetaData?.label});
if (!props.isModal) if (!props.isModal)
{ {
@ -549,13 +666,7 @@ function EntityForm(props: Props): JSX.Element
} }
const hasFields = section.fieldNames && section.fieldNames.length > 0; const hasFields = section.fieldNames && section.fieldNames.length > 0;
const hasChildRecordListWidget = section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList"; if(hasFields)
if (!hasFields && !hasChildRecordListWidget)
{
continue;
}
if (hasFields)
{ {
for (let j = 0; j < section.fieldNames.length; j++) for (let j = 0; j < section.fieldNames.length; j++)
{ {
@ -599,6 +710,7 @@ function EntityForm(props: Props): JSX.Element
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData); newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
newChildListWidgetData[section.widgetName] = widgetData; newChildListWidgetData[section.widgetName] = widgetData;
} }
////////////////////////////////////// //////////////////////////////////////
// capture the tier1 section's name // // capture the tier1 section's name //
////////////////////////////////////// //////////////////////////////////////
@ -762,6 +874,8 @@ function EntityForm(props: Props): JSX.Element
if (props.id !== null && !props.isCopy) if (props.id !== null && !props.isCopy)
{ {
recordAnalytics({category: "tableEvents", action: "saveEdit", label: tableMetaData?.label});
/////////////////////// ///////////////////////
// perform an update // // perform an update //
/////////////////////// ///////////////////////
@ -804,6 +918,8 @@ function EntityForm(props: Props): JSX.Element
} }
else else
{ {
recordAnalytics({category: "tableEvents", action: props.isCopy ? "saveCopy" : "saveNew", label: tableMetaData?.label});
///////////////////////////////// /////////////////////////////////
// perform an insert // // perform an insert //
// todo - audit if it's a dupe // // todo - audit if it's a dupe //
@ -849,6 +965,27 @@ function EntityForm(props: Props): JSX.Element
})(); })();
}; };
/*******************************************************************************
** process a form-field having a changed value (e.g., apply field rules).
*******************************************************************************/
function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: {[fieldName: string]: any})
{
for (let fieldRule of fieldRules)
{
if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName)
{
switch (fieldRule.action)
{
case FieldRuleAction.CLEAR_TARGET_FIELD:
console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`);
valueChangesToMake[fieldRule.targetField] = null;
break;
}
}
}
}
const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`; const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`;
let body; let body;
@ -887,7 +1024,7 @@ function EntityForm(props: Props): JSX.Element
else else
{ {
body = ( body = (
<Box mb={3}> <Box mb={3} className="entityForm">
{ {
(alertContent || warningContent) && (alertContent || warningContent) &&
<Grid container spacing={3}> <Grid container spacing={3}>
@ -924,51 +1061,116 @@ function EntityForm(props: Props): JSX.Element
errors, errors,
touched, touched,
isSubmitting, isSubmitting,
}) => ( setFieldValue,
<Form id={formId} autoComplete="off"> dirty
<ScrollToFirstError /> }) =>
{
/////////////////////////////////////////////////
// if we have values from formik, look at them //
/////////////////////////////////////////////////
if(values)
{
////////////////////////////////////////////////////////////////////////
// use stringified values as cheap/easy way to see if any are changed //
////////////////////////////////////////////////////////////////////////
const newFormValuesJSON = JSON.stringify(values);
if(formValuesJSON != newFormValuesJSON)
{
const valueChangesToMake: {[fieldName: string]: any} = {};
<Box pb={3} pt={0}> ////////////////////////////////////////////////////////////////////
<Card id={`${t1sectionName}`} sx={{overflow: "visible", pb: 2, scrollMarginTop: "100px"}} elevation={cardElevation}> // if the form is dirty (e.g., we're not doing the initial load), //
<Box display="flex" p={3} pb={1}> // then process rules for any changed fields //
<Box mr={1.5}> ////////////////////////////////////////////////////////////////////
<Avatar sx={{bgcolor: accentColor}}> if(dirty)
<Icon> {
{tableMetaData?.iconName} for (let fieldName in values)
</Icon>
</Avatar>
</Box>
<Box display="flex" alignItems="center">
<MDTypography variant="h5">{formTitle}</MDTypography>
</Box>
</Box>
{t1section && getSectionHelp(t1section)}
{ {
t1sectionName && formFields ? ( if (formValues[fieldName] != values[fieldName])
<Box px={3}> {
<Box pb={"0.25rem"} width="100%"> handleChangedFieldValue(fieldName, formValues[fieldName], values[fieldName], valueChangesToMake);
{getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)} }
</Box> formValues[fieldName] = values[fieldName];
</Box>
) : null
} }
</Card> }
</Box> else
{formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( {
<Box key={`edit-card-${section.name}`} pb={3}> /////////////////////////////////////////////////////////////////////////////////////
{renderSection(section, values, touched, formFields, errors)} // if the form is clean, make sure the formValues object has all form values in it //
/////////////////////////////////////////////////////////////////////////////////////
for (let fieldName in values)
{
formValues[fieldName] = values[fieldName];
}
}
/////////////////////////////////////////////////////////////////////////////
// if there were any changes to be made from the rule evaluation, //
// make those changes in the formValues map, and in formik (setFieldValue) //
/////////////////////////////////////////////////////////////////////////////
for (let fieldName in valueChangesToMake)
{
formValues[fieldName] = valueChangesToMake[fieldName];
setFieldValue(fieldName, valueChangesToMake[fieldName], false);
}
setFormValues(formValues)
setFormValuesJSON(JSON.stringify(values));
}
}
///////////////////////////////////////////////////////////////////
// once we're in the formik form, use its setFieldValue function //
// over top of the default one we created globally //
///////////////////////////////////////////////////////////////////
formikSetFieldValueFunction = setFieldValue;
return (
<Form id={formId} autoComplete="off">
<ScrollToFirstError />
<Box pb={3} pt={0}>
<Card id={`${t1sectionName}`} sx={{overflow: "visible", pb: 2, scrollMarginTop: "100px"}} elevation={cardElevation}>
<Box display="flex" p={3} pb={1}>
<Box mr={1.5}>
<Avatar sx={{bgcolor: accentColor}}>
<Icon>
{tableMetaData?.iconName}
</Icon>
</Avatar>
</Box>
<Box display="flex" alignItems="center">
<MDTypography variant="h5">{formTitle}</MDTypography>
</Box>
</Box>
{t1section && getSectionHelp(t1section)}
{
t1sectionName && formFields ? (
<Box px={3}>
<Box pb={"0.25rem"} width="100%">
{getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)}
</Box>
</Box>
) : null
}
</Card>
</Box> </Box>
)) : null} {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => (
<Box key={`edit-card-${section.name}`} pb={3}>
{renderSection(section, values, touched, formFields, errors)}
</Box>
)) : null}
<Box component="div" p={3}> <Box component="div" p={3}>
<Grid container justifyContent="flex-end" spacing={3}> <Grid container justifyContent="flex-end" spacing={3}>
<QCancelButton onClickHandler={props.isModal ? props.closeModalHandler : handleCancelClicked} disabled={isSubmitting} /> <QCancelButton onClickHandler={props.isModal ? props.closeModalHandler : handleCancelClicked} disabled={isSubmitting} />
<QSaveButton disabled={isSubmitting} /> <QSaveButton disabled={isSubmitting} />
</Grid> </Grid>
</Box> </Box>
</Form> </Form>
)} );
}}
</Formik> </Formik>
{ {

View File

@ -23,9 +23,11 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {Box} from "@mui/material";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import React, {ReactNode} from "react"; import React, {ReactNode, useState} from "react";
interface FieldAutoCompleteProps interface FieldAutoCompleteProps
{ {
@ -33,10 +35,17 @@ interface FieldAutoCompleteProps
metaData: QInstance; metaData: QInstance;
tableMetaData: QTableMetaData; tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void; handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string };
autoFocus?: boolean; autoFocus?: boolean;
forceOpen?: boolean; forceOpen?: boolean;
hiddenFieldNames?: string[]; hiddenFieldNames?: string[];
availableFieldNames?: string[];
variant?: "standard" | "filled" | "outlined";
label?: string;
textFieldSX?: any;
autocompleteSlotProps?: any;
hasError?: boolean;
noOptionsText?: string;
} }
FieldAutoComplete.defaultProps = FieldAutoComplete.defaultProps =
@ -44,17 +53,29 @@ FieldAutoComplete.defaultProps =
defaultValue: null, defaultValue: null,
autoFocus: false, autoFocus: false,
forceOpen: null, forceOpen: null,
hiddenFieldNames: [] hiddenFieldNames: [],
availableFieldNames: [],
variant: "standard",
label: "Field",
textFieldSX: null,
autocompleteSlotProps: null,
hasError: false,
noOptionsText: "No options",
}; };
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[]) function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string)
{ {
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++) for (let i = 0; i < sortedFields.length; i++)
{ {
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1) if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName)
{
continue;
}
if (availableFieldNames?.length && availableFieldNames.indexOf(fieldName) == -1)
{ {
continue; continue;
} }
@ -63,10 +84,16 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
} }
} }
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
/*******************************************************************************
** Component for rendering a list of field names from a table as an auto-complete.
*******************************************************************************/
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText}: FieldAutoCompleteProps): JSX.Element
{ {
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
const fieldOptions: any[] = []; const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
let fieldsGroupBy = null; let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
@ -77,7 +104,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
if (metaData.tables.has(exposedJoin.joinTable.name)) if (metaData.tables.has(exposedJoin.joinTable.name))
{ {
fieldsGroupBy = (option: any) => `${option.table.label} fields`; fieldsGroupBy = (option: any) => `${option.table.label} fields`;
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames); makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, availableFieldNames, selectedFieldName);
} }
} }
} }
@ -130,27 +157,48 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete // // seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
// doesn't open at all... so, only add the attribute at all, if forceOpen is true // // doesn't open at all... so, only add the attribute at all, if forceOpen is true //
/////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////
const alsoOpen: {[key: string]: any} = {} const alsoOpen: { [key: string]: any } = {};
if(forceOpen) if (forceOpen)
{ {
alsoOpen["open"] = forceOpen; alsoOpen["open"] = forceOpen;
} }
/*******************************************************************************
**
*******************************************************************************/
function onChange(event: any, newValue: any, reason: string)
{
setSelectedFieldName(newValue ? newValue.fieldName : null);
handleFieldChange(event, newValue, reason);
}
return ( return (
<Autocomplete <Autocomplete
id={id} id={id}
renderInput={(params) => (<TextField {...params} autoFocus={autoFocus} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)} renderInput={(params) =>
{
const inputProps = params.InputProps;
const originalEndAdornment = inputProps.endAdornment;
inputProps.endAdornment = <Box>
{hasError && <Icon color="error">error_outline</Icon>}
{originalEndAdornment}
</Box>;
return (<TextField {...params} autoFocus={autoFocus} label={label} variant={variant} sx={textFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
}}
// @ts-ignore // @ts-ignore
defaultValue={defaultValue} defaultValue={defaultValue}
options={fieldOptions} options={fieldOptions}
onChange={handleFieldChange} onChange={onChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy} groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)} getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)} renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true} autoSelect={true}
autoHighlight={true} autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} slotProps={autocompleteSlotProps ?? {}}
noOptionsText={noOptionsText}
{...alsoOpen} {...alsoOpen}
/> />

View File

@ -44,6 +44,7 @@ import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import RecordQueryView from "qqq/models/query/RecordQueryView"; import RecordQueryView from "qqq/models/query/RecordQueryView";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils"; import FilterUtils from "qqq/utils/qqq/FilterUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
import React, {useContext, useEffect, useRef, useState} from "react"; import React, {useContext, useEffect, useRef, useState} from "react";
@ -60,9 +61,10 @@ interface Props
viewAsJson?: string; viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void; viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean loadingSavedView: boolean
queryScreenUsage: QueryScreenUsage;
} }
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element
{ {
const navigate = useNavigate(); const navigate = useNavigate();
@ -91,6 +93,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const {accentColor, accentColorLight} = useContext(QContext); const {accentColor, accentColorLight} = useContext(QContext);
/////////////////////////////////////////////////////////////////////////////////////////
// this component is used by <RecordQuery> - but that component has different usages - //
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
// under the ReportSetupWidget). So, there are some behaviors we only want when we're //
// on the full-fledged query screen, such as changing the URL with saved view ids. //
/////////////////////////////////////////////////////////////////////////////////////////
const isQueryScreen = queryScreenUsage == "queryScreen";
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
const closeSavedViewsMenu = () => setSavedViewsMenu(null); const closeSavedViewsMenu = () => setSavedViewsMenu(null);
@ -142,7 +152,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(false); setSaveFilterPopupOpen(false);
closeSavedViewsMenu(); closeSavedViewsMenu();
viewOnChangeCallback(record.values.get("id")); viewOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); if(isQueryScreen)
{
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`);
}
}; };
@ -175,7 +188,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
case CLEAR_OPTION: case CLEAR_OPTION:
setSaveFilterPopupOpen(false) setSaveFilterPopupOpen(false)
viewOnChangeCallback(null); viewOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name)); if(isQueryScreen)
{
navigate(metaData.getTablePathByName(tableMetaData.name));
}
break; break;
case RENAME_OPTION: case RENAME_OPTION:
if(currentSavedView != null) if(currentSavedView != null)
@ -201,8 +217,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
{ {
const defaultValues: {[key: string]: any} = {}; const defaultValues: {[key: string]: any} = {};
defaultValues.tableName = tableMetaData.name; defaultValues.tableName = tableMetaData.name;
defaultValues.queryFilterJson = JSON.stringify(view.queryFilter, null, 3);
defaultValues.columnsJson = JSON.stringify(view.queryColumns, null, 3); let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
defaultValues.queryFilterJson = JSON.stringify(filterForBackend);
defaultValues.columnsJson = JSON.stringify(view.queryColumns);
navigate(`${metaData.getTablePathByName("savedReport")}/create#defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}`); navigate(`${metaData.getTablePathByName("savedReport")}/create#defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}`);
} }
@ -415,11 +435,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
open={Boolean(savedViewsMenu)} open={Boolean(savedViewsMenu)}
onClose={closeSavedViewsMenu} onClose={closeSavedViewsMenu}
keepMounted keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}} PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: "300px"}}}
> >
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
{ {
hasStorePermission && isQueryScreen &&
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
}
{
isQueryScreen && hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={<>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}> <Tooltip {...menuTooltipAttribs} title={<>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}> <MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon> <ListItemIcon><Icon>save</Icon></ListItemIcon>
@ -428,7 +451,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Tooltip> </Tooltip>
} }
{ {
hasStorePermission && currentSavedView != null && isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Change the name for this saved view."> <Tooltip {...menuTooltipAttribs} title="Change the name for this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}> <MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon> <ListItemIcon><Icon>edit</Icon></ListItemIcon>
@ -437,7 +460,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Tooltip> </Tooltip>
} }
{ {
hasStorePermission && currentSavedView != null && isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this view, with a different name, separate from the original."> <Tooltip {...menuTooltipAttribs} title="Save a new copy this view, with a different name, separate from the original.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}> <MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon> <ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
@ -446,7 +469,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Tooltip> </Tooltip>
} }
{ {
hasDeletePermission && currentSavedView != null && isQueryScreen && hasDeletePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Delete this saved view."> <Tooltip {...menuTooltipAttribs} title="Delete this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}> <MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon> <ListItemIcon><Icon>delete</Icon></ListItemIcon>
@ -455,6 +478,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Tooltip> </Tooltip>
} }
{ {
isQueryScreen &&
<Tooltip {...menuTooltipAttribs} title="Create a new view of this table, resetting the filters and columns to their defaults."> <Tooltip {...menuTooltipAttribs} title="Create a new view of this table, resetting the filters and columns to their defaults.">
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}> <MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon> <ListItemIcon><Icon>monitor</Icon></ListItemIcon>
@ -463,7 +487,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Tooltip> </Tooltip>
} }
{ {
hasSavedReportsPermission && isQueryScreen && hasSavedReportsPermission &&
<Tooltip {...menuTooltipAttribs} title="Create a new Saved Report using your current view of this table as a starting point."> <Tooltip {...menuTooltipAttribs} title="Create a new Saved Report using your current view of this table as a starting point.">
<MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}> <MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}>
<ListItemIcon><Icon>article</Icon></ListItemIcon> <ListItemIcon><Icon>article</Icon></ListItemIcon>
@ -471,7 +495,9 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</MenuItem> </MenuItem>
</Tooltip> </Tooltip>
} }
<Divider/> {
isQueryScreen && <Divider/>
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Views</b></MenuItem> <MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Views</b></MenuItem>
{ {
savedViews && savedViews.length > 0 ? ( savedViews && savedViews.length > 0 ? (
@ -481,7 +507,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</MenuItem> </MenuItem>
) )
): ( ): (
<MenuItem> <MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved views for this table.</i> <i>You do not have any saved views for this table.</i>
</MenuItem> </MenuItem>
) )
@ -580,25 +606,29 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}> <Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{ {
!currentSavedView && viewIsModified && <> !currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<> {
<b>Unsaved Changes</b> isQueryScreen && <>
<ul style={{padding: "0.5rem 1rem"}}> <Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
{ <b>Unsaved Changes</b>
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>) <ul style={{padding: "0.5rem 1rem"}}>
} {
</ul> viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
</>}> }
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button> </ul>
</Tooltip> </>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* vertical rule */} {/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" /> <Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
</>
}
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button> <Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</> </>
} }
{ {
currentSavedView && viewIsModified && <> isQueryScreen && currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<> <Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b> <b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}> <ul style={{padding: "0.5rem 1rem"}}>
@ -617,6 +647,34 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button> <Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
</> </>
} }
{
!isQueryScreen && currentSavedView &&
<Box>
<Box display="inline-block" fontSize="0.875rem" fontWeight="500" sx={{position: "relative", top: "-1px"}}>
{currentSavedView.values.get("label")}
</Box>
{
viewIsModified &&
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
<Box display="inline" ml="0.25rem" mr="0.25rem" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>with {viewDiffs.length} Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset Changes</Button>
</>
}
{/* vertical rule */}
<Box display="inline-block" ml="0.25rem" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset to New View</Button>
</Box>
}
</Box> </Box>
</Box> </Box>
{ {

View File

@ -0,0 +1,153 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import Box from "@mui/material/Box";
import colors from "qqq/assets/theme/base/colors";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import XIcon from "qqq/components/query/XIcon";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useState} from "react";
interface AdvancedQueryPreviewProps
{
tableMetaData: QTableMetaData;
queryFilter: QQueryFilter;
isEditable: boolean;
isQueryTooComplex: boolean;
removeCriteriaByIndexCallback: (index: number) => void;
}
/*******************************************************************************
** Box shown on query screen (and more??) to preview what a query looks like,
** as an "advanced" style/precursor-to-writing-your-own-query thing.
*******************************************************************************/
export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEditable, isQueryTooComplex, removeCriteriaByIndexCallback}: AdvancedQueryPreviewProps): JSX.Element
{
const [mouseOverElement, setMouseOverElement] = useState(null as string);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = (thisQueryFilter: QQueryFilter) =>
{
if (queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<React.Fragment>
{thisQueryFilter.criteria?.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{isEditable && !isQueryTooComplex && (
mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndexCallback(i)} /></span>
)}
{counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
</span>
);
}
else
{
return (<span />);
}
})}
{thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) =>
{
return (
<React.Fragment key={j}>
{j > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span></span>}
<span style={{display: "flex", marginRight: "0.20rem"}}>(</span>
{queryToAdvancedString(filter)}
<span style={{display: "flex", marginRight: "0.20rem"}}>)</span>
</React.Fragment>
);
}))}
</React.Fragment>
);
};
const moreSX = isEditable ?
{
borderTop: `1px solid ${colors.grayLines.main}`,
boxShadow: "inset 0px 0px 4px 2px #EFEFED",
borderRadius: "0 0 0.75rem 0.75rem",
} :
{
borderRadius: "0.75rem",
border: `1px solid ${colors.grayLines.main}`,
}
return (
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box
className="advancedQueryString"
display="inline-block"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.5rem"}
p={"0.5rem"}
pb={"0.125rem"}
{...moreSX}
>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryToAdvancedString(queryFilter)}
</Box>
</Box>
}
</Box>
)
}

View File

@ -44,11 +44,13 @@ import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import QContext from "QContext"; import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import FieldListMenu from "qqq/components/query/FieldListMenu"; import FieldListMenu from "qqq/components/query/FieldListMenu";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
import XIcon from "qqq/components/query/XIcon"; import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils"; import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
@ -75,12 +77,34 @@ interface BasicAndAdvancedQueryControlsProps
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
queryFilterJSON: string; queryFilterJSON: string;
queryScreenUsage: QueryScreenUsage;
mode: string; mode: string;
setMode: (mode: string) => void; setMode: (mode: string) => void;
} }
let debounceTimeout: string | number | NodeJS.Timeout; let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** function to generate an element that says how a filter is sorted.
*******************************************************************************/
export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData: QTableMetaData, toggleSortDirection: (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => void)
{
if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
return <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>;
}
else
{
return <>Sort...</>;
}
}
/******************************************************************************* /*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the ** Component to provide the basic & advanced query-filter controls for the
** RecordQueryOrig screen. ** RecordQueryOrig screen.
@ -397,60 +421,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
}; };
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = (thisQueryFilter: QQueryFilter) =>
{
if (queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<React.Fragment>
{thisQueryFilter.criteria?.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{!isQueryTooComplex && (
mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} /></span>
)}
{counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
</span>
);
}
else
{
return (<span />);
}
})}
{thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) =>
{
return (
<React.Fragment key={j}>
{j > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span></span>}
<span style={{display: "flex", marginRight: "0.20rem"}}>(</span>
{queryToAdvancedString(filter)}
<span style={{display: "flex", marginRight: "0.20rem"}}>)</span>
</React.Fragment>
);
}))}
</React.Fragment>
);
};
/******************************************************************************* /*******************************************************************************
** event handler for toggling between modes - basic & advanced. ** event handler for toggling between modes - basic & advanced.
*******************************************************************************/ *******************************************************************************/
@ -608,15 +578,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
///////////////////////////////// /////////////////////////////////
// set up the sort menu button // // set up the sort menu button //
///////////////////////////////// /////////////////////////////////
let sortButtonContents = <>Sort...</>; let sortButtonContents = getCurrentSortIndicator(queryFilter, tableMetaData, toggleSortDirection);
if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
sortButtonContents = <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... // // this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
@ -807,26 +769,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{sortMenuComponent} {sortMenuComponent}
</Box> </Box>
</Box> </Box>
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center"> <AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={queryFilter} isEditable={true} isQueryTooComplex={isQueryTooComplex} removeCriteriaByIndexCallback={removeCriteriaByIndex} />
{
<Box
className="advancedQueryString"
display="inline-block"
borderTop={`1px solid ${borderGray}`}
borderRadius="0 0 0.75rem 0.75rem"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.375rem"}
p={"0.5rem"}
pb={"0.125rem"}
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryToAdvancedString(queryFilter)}
</Box>
</Box>
}
</Box>
</Box> </Box>
} }
</Box> </Box>

View File

@ -23,8 +23,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro"; import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
import React from "react"; import QContext from "QContext";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext} from "react";
interface QExportMenuItemProps extends GridExportMenuItemProps<{}> interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{ {
@ -43,6 +44,10 @@ export default function ExportMenuItem(props: QExportMenuItemProps)
{ {
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props; const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
const {recordAnalytics} = useContext(QContext);
recordAnalytics({category: "tableEvents", action: "export", label: tableMetaData.label});
return ( return (
<MenuItem <MenuItem
disabled={totalRecords === 0} disabled={totalRecords === 0}

View File

@ -33,10 +33,10 @@ import MenuItem from "@mui/material/MenuItem";
import Select, {SelectChangeEvent} from "@mui/material/Select/Select"; import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import React, {ReactNode, SyntheticEvent, useState} from "react";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import FilterUtils from "qqq/utils/qqq/FilterUtils"; import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {ReactNode, SyntheticEvent, useState} from "react";
export enum ValueMode export enum ValueMode
@ -484,7 +484,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
: <span />} : <span />}
</Box> </Box>
<Box display="inline-block" width={250} className="fieldColumn"> <Box display="inline-block" width={250} className="fieldColumn">
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} /> <FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange}
autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
/>
</Box> </Box>
<Box display="inline-block" width={200} className="operatorColumn"> <Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}> <Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>

View File

@ -19,13 +19,13 @@
*/ */
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Skeleton} from "@mui/material"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Skeleton} from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import Tab from "@mui/material/Tab"; import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs"; import Tabs from "@mui/material/Tabs";
import parse from "html-react-parser"; import parse from "html-react-parser";
import React, {useContext, useEffect, useReducer, useState} from "react";
import QContext from "QContext"; import QContext from "QContext";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import TabPanel from "qqq/components/misc/TabPanel"; import TabPanel from "qqq/components/misc/TabPanel";
@ -39,18 +39,21 @@ import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer"; import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
import DividerWidget from "qqq/components/widgets/misc/Divider"; import DividerWidget from "qqq/components/widgets/misc/Divider";
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget"; import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart"; import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget"; import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer"; import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import StepperCard from "qqq/components/widgets/misc/StepperCard"; import StepperCard from "qqq/components/widgets/misc/StepperCard";
import USMapWidget from "qqq/components/widgets/misc/USMapWidget"; import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import ParentWidget from "qqq/components/widgets/ParentWidget"; import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard"; import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard"; import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
import Widget, {HeaderIcon, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, LabelComponent} from "qqq/components/widgets/Widget"; import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
import WidgetBlock from "qqq/components/widgets/WidgetBlock"; import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import ProcessRun from "qqq/pages/processes/ProcessRun"; import ProcessRun from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import React, {useContext, useEffect, useReducer, useState} from "react";
import TableWidget from "./tables/TableWidget"; import TableWidget from "./tables/TableWidget";
@ -61,6 +64,7 @@ interface Props
widgetMetaDataList: QWidgetMetaData[]; widgetMetaDataList: QWidgetMetaData[];
tableName?: string; tableName?: string;
entityPrimaryKey?: string; entityPrimaryKey?: string;
record?: QRecord;
omitWrappingGridContainer: boolean; omitWrappingGridContainer: boolean;
areChildren?: boolean; areChildren?: boolean;
childUrlParams?: string; childUrlParams?: string;
@ -79,7 +83,7 @@ DashboardWidgets.defaultProps = {
wrapWidgetsInTabPanels: false, wrapWidgetsInTabPanels: false,
}; };
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
{ {
const [widgetData, setWidgetData] = useState([] as any[]); const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetCounter, setWidgetCounter] = useState(0); const [widgetCounter, setWidgetCounter] = useState(0);
@ -91,9 +95,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
let initialSelectedTab = 0; let initialSelectedTab = 0;
let selectedTabKey: string = null; let selectedTabKey: string = null;
if(parentWidgetMetaData && wrapWidgetsInTabPanels) if (parentWidgetMetaData && wrapWidgetsInTabPanels)
{ {
selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}` selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}`;
if (localStorage.getItem(selectedTabKey)) if (localStorage.getItem(selectedTabKey))
{ {
initialSelectedTab = Number(localStorage.getItem(selectedTabKey)); initialSelectedTab = Number(localStorage.getItem(selectedTabKey));
@ -191,7 +195,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const metaDataToUse = (thisWidgetHasDropdowns) ? widgetMetaData : parentWidgetMetaData; const metaDataToUse = (thisWidgetHasDropdowns) ? widgetMetaData : parentWidgetMetaData;
for (let i = 0; i < metaDataToUse.dropdowns.length; i++) for (let i = 0; i < metaDataToUse.dropdowns.length; i++)
{ {
const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName; const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName ?? metaDataToUse.dropdowns[i].name;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${metaDataToUse.name}.${dropdownName}`; const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${metaDataToUse.name}.${dropdownName}`;
const json = JSON.parse(localStorage.getItem(localStorageKey)); const json = JSON.parse(localStorage.getItem(localStorageKey));
if (json) if (json)
@ -248,6 +252,23 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0; const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0;
/*******************************************************************************
** helper function, to convert values from a QRecord values map to a regular old
** js object
*******************************************************************************/
function convertQRecordValuesFromMapToObject(record: QRecord): {[name: string]: any}
{
const rs: {[name: string]: any} = {};
if(record.values)
{
record.values.forEach((value, key) => rs[key] = value);
}
return (rs);
}
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element => const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{ {
const labelAdditionalComponentsRight: LabelComponent[] = []; const labelAdditionalComponentsRight: LabelComponent[] = [];
@ -286,6 +307,21 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
/> />
) )
} }
{
widgetMetaData.type === "alert" && widgetData[i]?.html && (
<Widget
omitPadding={true}
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>{parse(widgetData[i]?.html)}</Alert>
</Widget>
)
}
{ {
widgetMetaData.type === "usaMap" && ( widgetMetaData.type === "usaMap" && (
<USMapWidget <USMapWidget
@ -546,11 +582,25 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
</Widget> </Widget>
) )
} }
{
widgetMetaData.type === "reportSetup" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<ReportSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{}} />
)
}
{
widgetMetaData.type === "pivotTableSetup" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{}} />
)
}
</Box> </Box>
); );
}; };
if(wrapWidgetsInTabPanels) if (wrapWidgetsInTabPanels)
{ {
omitWrappingGridContainer = true; omitWrappingGridContainer = true;
} }
@ -582,7 +632,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
</TabPanel>); </TabPanel>);
} }
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>) return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>);
}) })
} }
</> </>
@ -590,7 +640,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ? const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs <Tabs
sx={{m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3, sx={{
m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
"& .MuiTabs-scroller": { "& .MuiTabs-scroller": {
ml: 0 ml: 0
} }
@ -603,7 +654,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
<Tab key={widgetMetaData.name} label={widgetMetaData.label} /> <Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))} ))}
</Tabs> </Tabs>
: <></> : <></>;
return ( return (
widgetCount > 0 ? ( widgetCount > 0 ? (

View File

@ -21,21 +21,23 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {InputLabel} from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip/Tooltip"; import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import parse from "html-react-parser"; import parse from "html-react-parser";
import React, {useContext, useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
import QContext from "QContext"; import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu"; import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils"; import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import HtmlUtils from "qqq/utils/HtmlUtils"; import HtmlUtils from "qqq/utils/HtmlUtils";
import React, {useContext, useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
export interface WidgetData export interface WidgetData
{ {
@ -60,6 +62,7 @@ interface Props
labelAdditionalComponentsLeft: LabelComponent[]; labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[]; labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[]; labelAdditionalComponentsRight: LabelComponent[];
labelAdditionalElementsRight: JSX.Element[];
labelBoxAdditionalSx?: any; labelBoxAdditionalSx?: any;
widgetMetaData?: QWidgetMetaData; widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData; widgetData?: WidgetData;
@ -80,6 +83,7 @@ Widget.defaultProps = {
labelAdditionalComponentsLeft: [], labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [], labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [], labelAdditionalComponentsRight: [],
labelAdditionalElementsRight: [],
labelBoxAdditionalSx: {}, labelBoxAdditionalSx: {},
omitPadding: false, omitPadding: false,
}; };
@ -160,6 +164,79 @@ export class HeaderIcon extends LabelComponent
} }
/*******************************************************************************
** a link (actually a button) for in a widget's header
*******************************************************************************/
interface HeaderLinkButtonComponentProps
{
label: string;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderLinkButtonComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderLinkButtonComponent({label, onClickCallback, disabled, disabledTooltip}: HeaderLinkButtonComponentProps): JSX.Element
{
return (
<Tooltip title={disabledTooltip}>
<span>
<Button disabled={disabled} onClick={() => onClickCallback()} sx={{p: 0}} disableRipple>
<Typography display="inline" textTransform="none" fontSize={"1.125rem"}>
{label}
</Typography>
</Button>
</span>
</Tooltip>
);
}
/*******************************************************************************
**
*******************************************************************************/
interface HeaderToggleComponentProps
{
label: string;
getValue: () => boolean;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderToggleComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element
{
const onClick = () =>
{
onClickCallback();
}
return (
<Box alignItems="baseline" mr="-0.75rem">
<Tooltip title={disabledTooltip}>
<span>
<InputLabel sx={{fontSize: "1.125rem", px: "0 !important", cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.65 : 1}} unselectable="on">
{label} <Switch disabled={disabled} checked={getValue()} onClick={onClick} />
</InputLabel>
</span>
</Tooltip>
</Box>
);
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -240,12 +317,20 @@ export class Dropdown extends LabelComponent
if (localStorageOption) if (localStorageOption)
{ {
const id = localStorageOption.id; const id = localStorageOption.id;
for (let i = 0; i < this.options.length; i++)
if (this.dropdownMetaData.type == "DATE_PICKER")
{ {
if (this.options[i].id == id) defaultValue = id;
}
else
{
for (let i = 0; i < this.options.length; i++)
{ {
defaultValue = this.options[i]; if (this.options[i].id == id)
args.dropdownData[args.componentIndex] = defaultValue?.id; {
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
} }
} }
} }
@ -299,6 +384,7 @@ export class Dropdown extends LabelComponent
<Box mb={2} sx={{float: "right"}}> <Box mb={2} sx={{float: "right"}}>
<WidgetDropdownMenu <WidgetDropdownMenu
name={this.dropdownName} name={this.dropdownName}
type={this.dropdownMetaData.type}
defaultValue={defaultValue} defaultValue={defaultValue}
sx={{marginLeft: "1rem"}} sx={{marginLeft: "1rem"}}
label={label} label={label}
@ -564,6 +650,8 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick)); localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
} }
let localLabelAdditionalElementsRight = [...props.labelAdditionalElementsRight];
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
const isSet = (v: any): boolean => const isSet = (v: any): boolean =>
@ -580,6 +668,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0); needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= (localLabelAdditionalElementsRight && localLabelAdditionalElementsRight.length > 0);
needLabelBox ||= isSet(props.widgetData?.icon); needLabelBox ||= isSet(props.widgetData?.icon);
needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetData?.label);
needLabelBox ||= isSet(props.widgetMetaData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label);
@ -622,11 +711,11 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
setUsingLabelAsTitle(props.widgetData.isLabelPageTitle); setUsingLabelAsTitle(props.widgetData.isLabelPageTitle);
} }
const helpRoles = ["ALL_SCREENS"] const helpRoles = ["ALL_SCREENS"];
const slotName = "label"; const slotName = "label";
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles); const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if(showHelp) if (showHelp)
{ {
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />; const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
labelElement = <Tooltip title={formattedHelpContent} arrow={true} placement="bottom-start">{labelElement}</Tooltip>; labelElement = <Tooltip title={formattedHelpContent} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
@ -711,6 +800,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}) })
) )
} }
{localLabelAdditionalElementsRight}
</Box> </Box>
</Box> </Box>
} }

View File

@ -31,7 +31,7 @@ export default function TextBlock({widgetMetaData, data}: StandardBlockComponent
{ {
return ( return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot=""> <BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<span>{data.values.text}</span> <span style={{fontSize: "1.000rem"}}>{data.values.text}</span>
</BlockElementWrapper> </BlockElementWrapper>
); );
} }

View File

@ -19,18 +19,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Collapse, Theme, InputAdornment} from "@mui/material"; import {CalendarTodayOutlined} from "@mui/icons-material";
import {Collapse, InputAdornment, Theme} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {SxProps} from "@mui/system"; import {SxProps} from "@mui/system";
import {DatePicker, DateValidationError, LocalizationProvider, PickerChangeHandlerContext, PickerValidDate} from "@mui/x-date-pickers";
import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import {Field, Form, Formik} from "formik"; import {Field, Form, Formik} from "formik";
import React, {useState} from "react"; import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import MDInput from "qqq/components/legacy/MDInput"; import MDInput from "qqq/components/legacy/MDInput";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
export interface DropdownOption export interface DropdownOption
@ -45,6 +50,7 @@ export interface DropdownOption
interface Props interface Props
{ {
name: string; name: string;
type?: string;
defaultValue?: any; defaultValue?: any;
label?: string; label?: string;
startIcon?: string; startIcon?: string;
@ -96,7 +102,7 @@ function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndD
return (backendTimeValues); return (backendTimeValues);
} }
function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element function WidgetDropdownMenu({name, type, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
{ {
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,")); const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate); const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
@ -105,16 +111,27 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [dateValue, setDateValue] = useState(defaultValue);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [backDisabled, setBackDisabled] = useState(false); const [backDisabled, setBackDisabled] = useState(false);
const [forthDisabled, setForthDisabled] = useState(false); const [forthDisabled, setForthDisabled] = useState(false);
const {accentColor} = useContext(QContext);
const doForceOpen = (event: React.MouseEvent<HTMLDivElement>) => const doForceOpen = (event: React.MouseEvent<HTMLDivElement>) =>
{ {
setIsOpen(true); setIsOpen(true);
}; };
useEffect(() =>
{
if (type == "DATE_PICKER")
{
handleOnChange(null, defaultValue, null);
}
}, [defaultValue]);
function getSelectedIndex(value: DropdownOption) function getSelectedIndex(value: DropdownOption)
{ {
let currentIndex = null; let currentIndex = null;
@ -129,9 +146,19 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
return currentIndex; return currentIndex;
} }
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1) =>
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1, type: string) =>
{ {
event.stopPropagation(); event.stopPropagation();
if (type == "DATE_PICKER")
{
let currentDate = new Date(dateValue);
currentDate.setDate(currentDate.getDate() + direction);
handleOnChange(null, currentDate, null);
return;
}
let currentIndex = getSelectedIndex(value); let currentIndex = getSelectedIndex(value);
if (currentIndex == null) if (currentIndex == null)
@ -156,9 +183,26 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
}; };
const handleDatePickerOnChange = (value: PickerValidDate, context: PickerChangeHandlerContext<DateValidationError>) =>
{
if (value.isValid())
{
handleOnChange(null, value.toDate(), null);
}
};
const handleOnChange = (event: any, newValue: any, reason: string) => const handleOnChange = (event: any, newValue: any, reason: string) =>
{ {
setValue(newValue); if (type == "DATE_PICKER")
{
setDateValue(newValue);
newValue = {"id": new Date(newValue).toLocaleDateString()};
}
else
{
setValue(newValue);
}
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom"; const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom";
setCustomTimesVisible(isTimeframeCustom); setCustomTimesVisible(isTimeframeCustom);
@ -250,86 +294,123 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
const fontSize = "1rem"; const fontSize = "1rem";
let optionPaddingLeftRems = 0.75; let optionPaddingLeftRems = 0.75;
if(startIcon) if (startIcon)
{ {
optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75 optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75;
} }
if(allowBackAndForth) if (allowBackAndForth)
{ {
optionPaddingLeftRems += 2.5; optionPaddingLeftRems += 2.5;
} }
return ( if (type == "DATE_PICKER")
dropdownOptions ? ( {
<Box sx={{whiteSpace: "nowrap", display: "flex", return (
"& .MuiPopperUnstyled-root": { <Box sx={{
border: `1px solid ${colors.grayLines.main}`, ...sx,
borderTop: "none", background: "white",
borderRadius: "0 0 0.75rem 0.75rem", width: "250px",
padding: 0, borderRadius: "0.75rem !important",
}, "& .MuiPaper-rounded": { border: `1px solid ${colors.grayLines.main}`,
borderRadius: "0 0 0.75rem 0.75rem", "& *": {cursor: "pointer"}
} }} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
}} className="dashboardDropdownMenu"> {allowBackAndForth && <IconButton sx={{padding: 0, margin: "8px"}} onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1, type)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<Autocomplete <LocalizationProvider dateAdapter={AdapterDayjs}>
id={`${label}-combo-box`} <DatePicker
sx={{paddingRight: "8px"}}
defaultValue={defaultValue} defaultValue={dayjs(defaultValue)}
value={value} name={name}
onChange={handleOnChange} value={dayjs(dateValue)}
inputValue={inputValue} onChange={handleDatePickerOnChange}
onInputChange={handleOnInputChange} slots={{
openPickerIcon: CalendarTodayOutlined
isOptionEqualToValue={(option, value) => option.id === value.id} }}
slotProps={{
open={isOpen} openPickerIcon: {sx: {fontSize: "1.25rem !important", color: "#757575"}},
onOpen={() => setIsOpen(true)} actionBar: {actions: ["today"]},
onClose={() => setIsOpen(false)} textField: {variant: "standard", InputProps: {sx: {fontSize: "16px", color: "#495057"}, disableUnderline: true}}
}}
size="small" />
disablePortal </LocalizationProvider>
disableClearable={disableClearable} {allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1, type)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
options={dropdownOptions}
sx={{
...sx,
cursor: "pointer",
display: "inline-block",
"& .MuiOutlinedInput-notchedOutline": {
border: "none"
},
}}
renderInput={(params: any) =>
<>
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<TextField {...params} placeholder={label} sx={{
"& .MuiInputBase-input": {
fontSize: fontSize
}
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
/>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
</>
}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
)}
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
slotProps={{
popper: {
sx: {
width: `${width}px!important`
}
}
}}
/>
{customTimes}
</Box> </Box>
) : null );
); }
else
{
return (
dropdownOptions ? (
<Box sx={{
whiteSpace: "nowrap", display: "flex",
"& .MuiPopperUnstyled-root": {
border: `1px solid ${colors.grayLines.main}`,
borderTop: "none",
borderRadius: "0 0 0.75rem 0.75rem",
padding: 0,
}, "& .MuiPaper-rounded": {
borderRadius: "0 0 0.75rem 0.75rem",
}
}} className="dashboardDropdownMenu">
<Autocomplete
id={`${label}-combo-box`}
defaultValue={defaultValue}
value={value}
onChange={handleOnChange}
inputValue={inputValue}
onInputChange={handleOnInputChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
size="small"
disablePortal
disableClearable={disableClearable}
options={dropdownOptions}
sx={{
...sx,
cursor: "pointer",
display: "inline-block",
"& .MuiOutlinedInput-notchedOutline": {
border: "none"
},
}}
renderInput={(params: any) =>
<>
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1, type)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<TextField {...params} placeholder={label} sx={{
"& .MuiInputBase-input": {
fontSize: fontSize
}
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
/>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1, type)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
</>
}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
)}
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
slotProps={{
popper: {
sx: {
width: `${width}px!important`
}
}
}}
/>
{customTimes}
</Box>
) : null
);
}
} }
export default WidgetDropdownMenu; export default WidgetDropdownMenu;

View File

@ -119,7 +119,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
{ {
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "404") if ((e as QException).status === 404)
{ {
setNotFoundMessage("Data bag data could not be found."); setNotFoundMessage("Data bag data could not be found.");
return; return;

View File

@ -0,0 +1,209 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import type {Identifier, XYCoord} from "dnd-core";
import colors from "qqq/assets/theme/base/colors";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget";
import {PivotTableDefinition, PivotTableGroupBy} from "qqq/models/misc/PivotTableDefinitionModels";
import React, {FC, useRef} from "react";
import {useDrag, useDrop} from "react-dnd";
/*******************************************************************************
** component props
*******************************************************************************/
export interface PivotTableGroupByElementProps
{
id: string;
index: number;
dragCallback: (rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => void;
metaData: QInstance;
tableMetaData: QTableMetaData;
pivotTableDefinition: PivotTableDefinition;
usedGroupByFieldNames: string[];
availableFieldNames: string[];
isEditable: boolean;
groupBy: PivotTableGroupBy;
rowsOrColumns: "rows" | "columns";
callback: () => void;
attemptedSubmit?: boolean;
}
/*******************************************************************************
** item to support react-dnd
*******************************************************************************/
interface DragItem
{
index: number;
id: string;
type: string;
}
/*******************************************************************************
**
*******************************************************************************/
export const PivotTableGroupByElement: FC<PivotTableGroupByElementProps> = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback, attemptedSubmit}) =>
{
////////////////////////////////////////////////////////////////////////////
// credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple //
////////////////////////////////////////////////////////////////////////////
const ref = useRef<HTMLDivElement>(null);
const [{handlerId}, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>(
{
accept: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN,
collect(monitor)
{
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item: DragItem, monitor)
{
if (!ref.current)
{
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex)
{
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY)
{
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY)
{
return;
}
// Time to actually perform the action
dragCallback(rowsOrColumns, dragIndex, hoverIndex);
// Note: we're mutating the monitor item here! Generally it's better to avoid mutations,
// but it's good here for the sake of performance to avoid expensive index searches.
item.index = hoverIndex;
},
});
const [{isDragging}, drag, preview] = useDrag({
type: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN,
item: () =>
{
return {id, index};
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
/*******************************************************************************
**
*******************************************************************************/
const handleFieldChange = (event: any, newValue: any, reason: string) =>
{
groupBy.fieldName = newValue ? newValue.fieldName : null;
callback();
};
/*******************************************************************************
**
*******************************************************************************/
function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns")
{
pivotTableDefinition[rowsOrColumns].splice(index, 1);
callback();
}
if (!isEditable)
{
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName);
if (selectedField)
{
const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label;
return (<Box><Box display="inline-block" mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{label}</Box></Box>);
}
return (<React.Fragment />);
}
preview(drop(ref));
const showError = attemptedSubmit && !groupBy.fieldName;
return (<Box ref={ref} display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center" sx={{backgroundColor: "white", opacity: isDragging ? 0 : 1}} data-handler-id={handlerId}>
<Box>
<Icon ref={drag} sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`${rowsOrColumns}-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
hiddenFieldNames={usedGroupByFieldNames}
availableFieldNames={availableFieldNames}
defaultValue={getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName)}
hasError={showError}
noOptionsText="There are no fields available."
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeGroupBy(index, rowsOrColumns)}><Icon>clear</Icon></Button>
</Box>
</Box>);
};

View File

@ -0,0 +1,870 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Alert from "@mui/material/Alert";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {PivotTableGroupByElement} from "qqq/components/widgets/misc/PivotTableGroupByElement";
import {PivotTableValueElement} from "qqq/components/widgets/misc/PivotTableValueElement";
import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/ReportSetupWidget";
import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget";
import {PivotObjectKey, PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableGroupBy, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels";
import QQueryColumns from "qqq/models/query/QQueryColumns";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useCallback, useContext, useEffect, useReducer, useState} from "react";
import {DndProvider} from "react-dnd";
import {HTML5Backend} from "react-dnd-html5-backend";
export const DragItemTypes =
{
ROW: "row",
COLUMN: "column",
VALUE: "value"
};
export const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "40px",
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
export const fieldAutoCompleteTextFieldSX =
{
"& .MuiInputBase-input": {fontSize: "1rem", padding: "0 !important"}
};
/*******************************************************************************
**
*******************************************************************************/
export function getSelectedFieldForAutoComplete(tableMetaData: QTableMetaData, fieldName: string)
{
if (fieldName)
{
let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName);
if (field && fieldTable)
{
return ({field: field, table: fieldTable, fieldName: fieldName});
}
}
return (null);
}
/*******************************************************************************
** component props
*******************************************************************************/
interface PivotTableSetupWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
}
/*******************************************************************************
** default values for props
*******************************************************************************/
PivotTableSetupWidget.defaultProps = {
onSaveCallback: null
};
const qController = Client.getInstance();
/*******************************************************************************
** Component to edit the setup of a Pivot Table - rows, columns, values!
*******************************************************************************/
export default function PivotTableSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: PivotTableSetupWidgetProps): JSX.Element
{
const [metaData, setMetaData] = useState(null as QInstance);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [modalOpen, setModalOpen] = useState(false);
const [enabled, setEnabled] = useState(!!recordValues["usePivotTable"]);
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
const [errorAlert, setErrorAlert] = useState(null as string);
const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
///////////////////////////////////////////////////////////////////////////////////
// this is a copy of pivotTableDefinition, that we'll render in the modal. //
// then on-save, we'll move it to pivotTableDefinition, e.g., the actual record. //
///////////////////////////////////////////////////////////////////////////////////
const [modalPivotTableDefinition, setModalPivotTableDefinition] = useState(null as PivotTableDefinition);
const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]);
const [usedValueFieldNames, setUsedValueByFieldNames] = useState([] as string[]);
const [availableFieldNames, setAvailableFieldNames] = useState([] as string[]);
const {helpHelpActive} = useContext(QContext);
//////////////////
// initial load //
//////////////////
useEffect(() =>
{
if (!pivotTableDefinition)
{
let originalPivotTableDefinition = recordValues["pivotTableJson"] && JSON.parse(recordValues["pivotTableJson"]) as PivotTableDefinition;
if (originalPivotTableDefinition)
{
setEnabled(true);
}
else if (!originalPivotTableDefinition)
{
originalPivotTableDefinition = new PivotTableDefinition();
}
for (let i = 0; i < originalPivotTableDefinition?.rows?.length; i++)
{
if (!originalPivotTableDefinition?.rows[i].key)
{
originalPivotTableDefinition.rows[i].key = PivotObjectKey.next();
}
}
for (let i = 0; i < originalPivotTableDefinition?.columns?.length; i++)
{
if (!originalPivotTableDefinition?.columns[i].key)
{
originalPivotTableDefinition.columns[i].key = PivotObjectKey.next();
}
}
for (let i = 0; i < originalPivotTableDefinition?.values?.length; i++)
{
if (!originalPivotTableDefinition?.values[i].key)
{
originalPivotTableDefinition.values[i].key = PivotObjectKey.next();
}
}
setPivotTableDefinition(originalPivotTableDefinition);
updateUsedGroupByFieldNames(originalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
}
if (recordValues["columnsJson"])
{
updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns);
}
(async () =>
{
setMetaData(await qController.loadMetaData());
})();
});
/////////////////////////////////////////////////////////////////////
// handle the table name changing - load current table's meta-data //
/////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
{
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]);
setTableMetaData(tableMetaData);
})();
}
}, [recordValues]);
const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
/*******************************************************************************
**
*******************************************************************************/
function showHelp(slot: string)
{
return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles));
}
/*******************************************************************************
**
*******************************************************************************/
function getHelpContent(slot: string)
{
const key = `widget:${widgetMetaData.name};slot:${slot}`;
return <HelpContent helpContents={widgetMetaData?.helpContent?.get(slot)} roles={helpRoles} helpContentKey={key} />;
}
/*******************************************************************************
**
*******************************************************************************/
function toggleEnabled()
{
const newEnabled = !!!getEnabled();
setEnabled(newEnabled);
onSaveCallback({usePivotTable: newEnabled});
if (!newEnabled)
{
onSaveCallback({pivotTableJson: null});
}
}
/*******************************************************************************
**
*******************************************************************************/
function getEnabled()
{
return (enabled);
}
/*******************************************************************************
**
*******************************************************************************/
function addGroupBy(rowsOrColumns: "rows" | "columns")
{
if (!modalPivotTableDefinition[rowsOrColumns])
{
modalPivotTableDefinition[rowsOrColumns] = [];
}
modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy());
validateForm()
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function childElementChangedCallback()
{
updateUsedGroupByFieldNames(modalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
validateForm()
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function addValue()
{
if (!modalPivotTableDefinition.values)
{
modalPivotTableDefinition.values = [];
}
modalPivotTableDefinition.values.push(new PivotTableValue());
validateForm()
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function removeValue(index: number)
{
modalPivotTableDefinition.values.splice(index, 1);
validateForm()
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function updateUsedGroupByFieldNames(ptd: PivotTableDefinition = pivotTableDefinition)
{
const usedFieldNames: string[] = [];
for (let i = 0; i < ptd?.rows?.length; i++)
{
usedFieldNames.push(ptd?.rows[i].fieldName);
}
for (let i = 0; i < ptd?.columns?.length; i++)
{
usedFieldNames.push(ptd?.columns[i].fieldName);
}
setUsedGroupByFieldNames(usedFieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function updateUsedValueFieldNames(ptd: PivotTableDefinition = pivotTableDefinition)
{
const usedFieldNames: string[] = [];
for (let i = 0; i < ptd?.values?.length; i++)
{
usedFieldNames.push(ptd?.values[i].fieldName);
}
setUsedValueByFieldNames(usedFieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function updateAvailableFieldNames(columns: QQueryColumns)
{
const fieldNames: string[] = [];
for (let i = 0; i < columns?.columns?.length; i++)
{
if (columns.columns[i].isVisible)
{
fieldNames.push(columns.columns[i].name);
}
}
setAvailableFieldNames(fieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function renderOneValue(value: PivotTableValue, index: number)
{
if (!isEditable)
{
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName);
if (selectedField && value.function)
{
const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label;
return (<Box mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{pivotTableFunctionLabels[value.function]} of {label}</Box>);
}
return (<React.Fragment />);
}
const handleFieldChange = (event: any, newValue: any, reason: string) =>
{
value.fieldName = newValue ? newValue.fieldName : null;
};
const handleFunctionChange = (event: any, newValue: any, reason: string) =>
{
value.function = newValue ? newValue.id : null;
};
const functionOptions: any[] = [];
let defaultFunctionValue = null;
for (let pivotTableFunctionKey in PivotTableFunction)
{
// @ts-ignore any?
const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey];
const option = {id: pivotTableFunctionKey, label: label};
functionOptions.push(option);
if (option.id == value.function)
{
defaultFunctionValue = option;
}
}
// maybe cursor:grab (and then change to "grabbing")
return (<Box display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center">
<Box>
<Icon sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`values-field-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
defaultValue={getSelectedFieldForAutoComplete(tableMetaData, value.fieldName)}
/>
</Box>
<Box width="330px">
<Autocomplete
id={`values-field-${index}`}
renderInput={(params) => (<TextField {...params} label={null} variant="outlined" sx={fieldAutoCompleteTextFieldSX} autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @ts-ignore
defaultValue={defaultFunctionValue}
options={functionOptions}
onChange={handleFunctionChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.label}
// todo? renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
disableClearable
// slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
// {...alsoOpen}
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeValue(index)}><Icon>clear</Icon></Button>
</Box>
</Box>);
}
/*******************************************************************************
** drag & drop callback to move one of the pivot-table group-bys (rows/columns)
*******************************************************************************/
const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) =>
{
const array = modalPivotTableDefinition[rowsOrColumns];
const dragItem = array[dragIndex];
array.splice(dragIndex, 1);
array.splice(hoverIndex, 0, dragItem);
forceUpdate();
}, [modalPivotTableDefinition]);
/*******************************************************************************
** drag & drop callback to move one of the pivot-table values
*******************************************************************************/
const moveValue = useCallback((dragIndex: number, hoverIndex: number) =>
{
const array = modalPivotTableDefinition.values;
const dragItem = array[dragIndex];
array.splice(dragIndex, 1);
array.splice(hoverIndex, 0, dragItem);
forceUpdate();
}, [modalPivotTableDefinition]);
const noTable = (tableMetaData == null);
const noColumns = (!availableFieldNames || availableFieldNames.length == 0);
const selectTableFirstTooltipTitle = noTable ? "You must select a table before you can set up your pivot table" : null;
const selectColumnsFirstTooltipTitle = noColumns ? "You must set up your report's Columns before you can set up your Pivot Table" : null;
const editPopupDisabled = noTable || noColumns;
/////////////////////////////////////////////////////////////
// add toggle component to widget header for editable mode //
/////////////////////////////////////////////////////////////
const labelAdditionalElementsRight: JSX.Element[] = [];
if (isEditable)
{
labelAdditionalElementsRight.push(<HeaderToggleComponent disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
}
/*******************************************************************************
** render a group-by (row or column)
*******************************************************************************/
const renderGroupBy = useCallback((groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number, forModal: boolean) =>
{
return (
<PivotTableGroupByElement
key={groupBy.fieldName}
index={index}
id={`${groupBy.key}`}
dragCallback={moveGroupBy}
metaData={metaData}
tableMetaData={tableMetaData}
pivotTableDefinition={forModal ? modalPivotTableDefinition : pivotTableDefinition}
usedGroupByFieldNames={[...usedGroupByFieldNames, ...usedValueFieldNames]}
availableFieldNames={availableFieldNames}
isEditable={isEditable && forModal}
groupBy={groupBy}
rowsOrColumns={rowsOrColumns}
callback={childElementChangedCallback}
attemptedSubmit={attemptedSubmit}
/>
);
},
[tableMetaData, usedGroupByFieldNames, availableFieldNames],
);
/*******************************************************************************
** render a pivot-table value (row or column)
*******************************************************************************/
const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) =>
{
return (
<PivotTableValueElement
key={value.key}
index={index}
id={`${value.key}`}
dragCallback={moveValue}
metaData={metaData}
tableMetaData={tableMetaData}
pivotTableDefinition={forModal ? modalPivotTableDefinition : pivotTableDefinition}
availableFieldNames={availableFieldNames}
usedGroupByFieldNames={usedGroupByFieldNames}
isEditable={isEditable && forModal}
value={value}
callback={childElementChangedCallback}
attemptedSubmit={attemptedSubmit}
/>
);
},
[tableMetaData, usedGroupByFieldNames, availableFieldNames],
);
/*******************************************************************************
**
*******************************************************************************/
function openEditor()
{
if (recordValues["tableName"])
{
setModalPivotTableDefinition(Object.assign({}, pivotTableDefinition));
setModalOpen(true);
setAttemptedSubmit(false);
}
}
/*******************************************************************************
**
*******************************************************************************/
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
{
if (reason == "backdropClick" || reason == "escapeKeyDown")
{
return;
}
setModalOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function renderGroupBys(forModal: boolean, rowsOrColumns: "rows" | "columns")
{
const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition;
return <>
<h5>{rowsOrColumns == "rows" ? "Rows" : "Columns"}</h5>
<Box fontSize="1rem">
{
tableMetaData && (<div>{ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}</div>)
}
</Box>
{
(forModal || (isEditable && !ptd[rowsOrColumns]?.length)) &&
<Box mt={forModal ? "0.5rem" : "0"} mb="1rem">
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span><Button disabled={editPopupDisabled} sx={forModal ? buttonSX : unborderedButtonSX} onClick={() => forModal ? addGroupBy(rowsOrColumns) : openEditor()}>+ Add new {rowsOrColumns == "rows" ? "row" : "column"}</Button></span>
</Tooltip>
</Box>
}
{
!isEditable && !forModal && !ptd[rowsOrColumns]?.length &&
<Box color={colors.gray.main} fontSize="1rem">Your pivot table has no {rowsOrColumns}.</Box>
}
</>;
}
/*******************************************************************************
**
*******************************************************************************/
function renderValues(forModal: boolean)
{
const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition;
return <>
<h5>Values</h5>
<Box fontSize="1rem">
{
tableMetaData && (<div>{ptd?.values?.map((value, i) => renderValue(value, i, forModal))}</div>)
}
</Box>
{
(forModal || (isEditable && !ptd?.values?.length)) &&
<Box mt={forModal ? "0.5rem" : "0"} mb="1rem">
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span><Button disabled={editPopupDisabled} sx={forModal ? buttonSX : unborderedButtonSX} onClick={() => forModal ? addValue() : openEditor()}>+ Add new value</Button></span>
</Tooltip>
</Box>
}
{
!isEditable && !forModal && !ptd?.values?.length &&
<Box color={colors.gray.main} fontSize="1rem">Your pivot table has no values.</Box>
}
</>;
}
/*******************************************************************************
**
*******************************************************************************/
function validateForm(submitting: boolean = false)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if this isn't a call from the on-submit handler, and we haven't previously attempted a submit, then return w/o setting any alerts //
// this is like a version of considering "touched"... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(!submitting && !attemptedSubmit)
{
return;
}
let missingValues = 0;
for (let i = 0; i < modalPivotTableDefinition?.rows?.length; i++)
{
if (!modalPivotTableDefinition.rows[i].fieldName)
{
missingValues++;
}
}
for (let i = 0; i < modalPivotTableDefinition?.columns?.length; i++)
{
if (!modalPivotTableDefinition.columns[i].fieldName)
{
missingValues++;
}
}
for (let i = 0; i < modalPivotTableDefinition?.values?.length; i++)
{
if (!modalPivotTableDefinition.values[i].fieldName)
{
missingValues++;
}
if (!modalPivotTableDefinition.values[i].function)
{
missingValues++;
}
}
if (missingValues == 0)
{
setErrorAlert(null);
////////////////////////////////////////////////////////////////////////////////////
// this is to catch the case of - user attempted to submit, and there were errors //
// now they've fixed 'em - so go back to a 'clean' state - so if they add more //
// boxes, they won't immediately show errors, until a re-submit //
////////////////////////////////////////////////////////////////////////////////////
if(attemptedSubmit)
{
setAttemptedSubmit(false);
}
return (false);
}
setErrorAlert(`Missing value in ${missingValues} field${missingValues == 1 ? "" : "s"}.`);
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
function saveClicked()
{
setAttemptedSubmit(true);
if (validateForm(true))
{
return;
}
if (!onSaveCallback)
{
console.log("onSaveCallback was not defined");
return;
}
setPivotTableDefinition(Object.assign({}, modalPivotTableDefinition));
updateUsedGroupByFieldNames(modalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
onSaveCallback({pivotTableJson: JSON.stringify(modalPivotTableDefinition)});
closeEditor();
}
////////////
// render //
////////////
return (<Widget widgetMetaData={widgetMetaData} labelAdditionalElementsRight={labelAdditionalElementsRight}>
{
<React.Fragment>
<DndProvider backend={HTML5Backend}>
{
enabled &&
<Box display="flex" justifyContent="space-between">
<Box>
{
showHelp("sectionSubhead") &&
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"}>
{getHelpContent("sectionSubhead")}
</Box>
}
</Box>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span>
<Button disabled={editPopupDisabled} onClick={() => openEditor()} sx={{p: 0}} disableRipple>
<Typography display="inline" textTransform="none" fontSize={"1.125rem"}>
Edit Pivot Table
</Typography>
</Button>
</span>
</Tooltip>
}
</Box>
}
{
(!enabled || !pivotTableDefinition) && !isEditable &&
<Box fontSize="1rem">Your report does not use a Pivot Table.</Box>
}
{
enabled && pivotTableDefinition &&
<>
<Grid container spacing="16">
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(false, "rows")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(false, "columns")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderValues(false)}</Grid>
</Grid>
{
modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>
<div>
<Box sx={{position: "absolute", width: "100%"}}>
<Card sx={{m: "2rem", p: "2rem", overflowY: "auto", height: "calc(100vh - 4rem)"}}>
<h3>Edit Pivot Table</h3>
{
showHelp("modalSubheader") &&
<Box color={colors.gray.main}>
{getHelpContent("modalSubheader")}
</Box>
}
{
errorAlert && <Alert icon={<Icon>error_outline</Icon>} color="error" onClose={() => setErrorAlert(null)}>{errorAlert}</Alert>
}
<Grid container spacing="16" overflow="auto" mt="0.5rem" mb="1rem" height="100%">
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(true, "rows")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(true, "columns")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderValues(true)}</Grid>
</Grid>
<Box>
<Box display="flex" justifyContent="flex-end">
<QCancelButton disabled={false} onClickHandler={closeEditor} />
<QSaveButton label="OK" iconName="check" disabled={false} onClickHandler={saveClicked} />
</Box>
</Box>
</Card>
</Box>
</div>
</Modal>
}
</>
}
</DndProvider>
</React.Fragment>
}
</Widget>);
}
/* this was a rough-draft of what a preview of a pivot could look like...
<Box mt={"1rem"}>
<h5>Preview</h5>
<table>
<tr>
<th style={{textAlign: "left", fontSize: "0.875rem"}}></th>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>Column Labels</th>
</tr>
{
pivotTableDefinition?.columns?.map((column, i) =>
(
<tr key={column.key}>
<th style={{textAlign: "left", fontSize: "0.875rem"}}></th>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>{column.fieldName}</th>
</tr>
))
}
<tr>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>Row Labels</th>
{
pivotTableDefinition?.values?.map((value, i) =>
(
<th key={value.key} style={{textAlign: "left", fontSize: "0.875rem"}}>{value.function} of {value.fieldName}</th>
))
}
</tr>
{
pivotTableDefinition?.rows?.map((row, i) =>
(
<tr key={row.key}>
<th style={{textAlign: "left", fontSize: "0.875rem", paddingLeft: (i * 1) + "rem"}}>{row.fieldName}</th>
</tr>
))
}
</table>
</Box>
*/

View File

@ -0,0 +1,319 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import type {Identifier, XYCoord} from "dnd-core";
import colors from "qqq/assets/theme/base/colors";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget";
import {functionsPerFieldType, PivotTableDefinition, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels";
import React, {FC, useReducer, useRef, useState} from "react";
import {useDrag, useDrop} from "react-dnd";
/*******************************************************************************
** component props
*******************************************************************************/
export interface PivotTableValueElementProps
{
id: string;
index: number;
dragCallback: (dragIndex: number, hoverIndex: number) => void;
metaData: QInstance;
tableMetaData: QTableMetaData;
pivotTableDefinition: PivotTableDefinition;
availableFieldNames: string[];
usedGroupByFieldNames: string[];
isEditable: boolean;
value: PivotTableValue;
callback: () => void;
attemptedSubmit?: boolean;
}
/*******************************************************************************
** item to support react-dnd
*******************************************************************************/
interface DragItem
{
index: number;
id: string;
type: string;
}
/*******************************************************************************
** Element to render 1 pivot-table value.
*******************************************************************************/
export const PivotTableValueElement: FC<PivotTableValueElementProps> = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, usedGroupByFieldNames, value, isEditable, callback, attemptedSubmit}) =>
{
const [defaultFunctionValue, setDefaultFunctionValue] = useState(null);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
////////////////////////////////////////////////////////////////////////////
// credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple //
////////////////////////////////////////////////////////////////////////////
const ref = useRef<HTMLDivElement>(null);
const [{handlerId}, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>(
{
accept: DragItemTypes.VALUE,
collect(monitor)
{
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item: DragItem, monitor)
{
if (!ref.current)
{
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex)
{
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY)
{
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY)
{
return;
}
// Time to actually perform the action
dragCallback(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here! Generally it's better to avoid mutations,
// but it's good here for the sake of performance to avoid expensive index searches.
item.index = hoverIndex;
},
});
const [{isDragging}, drag] = useDrag({
type: DragItemTypes.VALUE,
item: () =>
{
return {id, index};
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
/*******************************************************************************
**
*******************************************************************************/
function getFunctionsForField(field: QFieldMetaData)
{
if(field)
{
let type = field.type;
if (field.possibleValueSourceName)
{
type = QFieldType.STRING;
}
if(functionsPerFieldType[type])
{
return (functionsPerFieldType[type]);
}
}
//////////////////////////////////////
// return broadest list if no field //
//////////////////////////////////////
return (functionsPerFieldType[QFieldType.INTEGER]);
}
/*******************************************************************************
** event handler for user selecting a field
*******************************************************************************/
function handleFieldChange(event: any, newValue: any, reason: string)
{
value.fieldName = newValue ? newValue.fieldName : null;
if(newValue)
{
/////////////////////////////////////////////////////////////////////////////////////////
// if newly selected field doesn't have the currently selected function, then clear it //
/////////////////////////////////////////////////////////////////////////////////////////
const newSelectedField = getSelectedFieldForAutoComplete(tableMetaData, newValue.fieldName);
if (newSelectedField)
{
if(getFunctionsForField(newSelectedField.field).indexOf(value.function) == -1)
{
setDefaultFunctionValue(null);
handleFunctionChange(null, null, null);
forceUpdate();
}
}
}
callback();
}
/*******************************************************************************
** event handler for user selecting a function
*******************************************************************************/
function handleFunctionChange(event: any, newValue: any, reason: string)
{
value.function = newValue ? newValue.id : null;
callback();
}
/*******************************************************************************
** event handler for clicking remove button
*******************************************************************************/
function removeValue(index: number)
{
pivotTableDefinition.values.splice(index, 1);
callback();
}
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName);
/////////////////////////////////////////////////////////////////////
// if we're not on an edit screen, return a simpler read-only view //
/////////////////////////////////////////////////////////////////////
if (!isEditable)
{
let label = "--";
if (selectedField && value.function)
{
label = pivotTableFunctionLabels[value.function] + " of " + (selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label);
}
return (<Box><Box display="inline-block" mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{label}</Box></Box>);
}
///////////////////////////////////////////////////////////////////////////////
// figure out functions to display in drop down, plus selected/default value //
///////////////////////////////////////////////////////////////////////////////
const functionOptions: any[] = [];
const availableFunctions = getFunctionsForField(selectedField?.field);
for (let pivotTableFunction of availableFunctions)
{
const label = pivotTableFunctionLabels[pivotTableFunction];
const option = {id: pivotTableFunction, label: label};
functionOptions.push(option);
if (option.id == value.function && JSON.stringify(option) != JSON.stringify(defaultFunctionValue))
{
setDefaultFunctionValue(option);
}
}
drag(drop(ref));
const showValueError = attemptedSubmit && !value.fieldName;
const showFunctionError = attemptedSubmit && !value.function;
return (<Box ref={ref} display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center" sx={{backgroundColor: "white", opacity: isDragging ? 0 : 1}} data-handler-id={handlerId}>
<Box>
<Icon sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`values-field-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
availableFieldNames={availableFieldNames}
hiddenFieldNames={usedGroupByFieldNames}
defaultValue={selectedField}
hasError={showValueError}
noOptionsText="There are no fields available."
/>
</Box>
<Box width="370px">
<Autocomplete
id={`values-function-${index}`}
renderInput={(params) =>
{
const inputProps = params.InputProps;
const originalEndAdornment = inputProps.endAdornment;
inputProps.endAdornment = <Box>
{showFunctionError && <Icon color="error">error_outline</Icon>}
{originalEndAdornment}
</Box>;
return (<TextField {...params} label={null} variant="outlined" sx={fieldAutoCompleteTextFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
}}
// @ts-ignore
value={defaultFunctionValue}
inputValue={defaultFunctionValue?.label ?? ""}
options={functionOptions}
onChange={handleFunctionChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeValue(index)}><Icon>clear</Icon></Button>
</Box>
</Box>);
};

View File

@ -0,0 +1,374 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Collapse} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview";
import {getCurrentSortIndicator} from "qqq/components/query/BasicAndAdvancedQueryControls";
import Widget, {HeaderLinkButtonComponent} from "qqq/components/widgets/Widget";
import QQueryColumns, {Column} from "qqq/models/query/QQueryColumns";
import RecordQuery from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
interface ReportSetupWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
recordValues: {[name: string]: any};
onSaveCallback?: (values: {[name: string]: any}) => void;
}
ReportSetupWidget.defaultProps = {
onSaveCallback: null
};
export const buttonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
paddingLeft: "1rem",
paddingRight: "1rem",
opacity: "1",
color: colors.dark.main,
"&:hover": {color: colors.dark.main},
"&:focus": {color: colors.dark.main},
"&:focus:not(:hover)": {color: colors.dark.main},
};
export const unborderedButtonSX = Object.assign({}, buttonSX);
unborderedButtonSX.border = "none !important";
unborderedButtonSX.opacity = "0.7";
const qController = Client.getInstance();
/*******************************************************************************
** Component for editing the main setup of a report - that is: filter & columns
*******************************************************************************/
export default function ReportSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: ReportSetupWidgetProps): JSX.Element
{
const [modalOpen, setModalOpen] = useState(false);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [alertContent, setAlertContent] = useState(null as string);
//////////////////////////////////////////////////////////////////////////////////////////////////
// we'll actually keep 2 copies of the query filter around here - //
// the one in the record (as json) is one that the backend likes (e.g., possible values as ids) //
// this "frontend" one is one that the frontend can use (possible values as objects w/ labels). //
//////////////////////////////////////////////////////////////////////////////////////////////////
const [frontendQueryFilter, setFrontendQueryFilter] = useState(null as QQueryFilter);
const {helpHelpActive} = useContext(QContext);
const recordQueryRef = useRef();
/////////////////////////////
// load values from record //
/////////////////////////////
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
let usingDefaultEmptyFilter = false;
if(!queryFilter)
{
queryFilter = new QQueryFilter();
usingDefaultEmptyFilter = true;
}
let columns: QQueryColumns = null;
if(recordValues["columnsJson"])
{
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
}
//////////////////////////////////////////////////////////////////////
// load tableMetaData initially, and if/when selected table changes //
//////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
{
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"])
setTableMetaData(tableMetaData);
const queryFilterForFrontend = Object.assign({}, queryFilter);
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend)
setFrontendQueryFilter(queryFilterForFrontend)
})();
}
}, [recordValues]);
/*******************************************************************************
**
*******************************************************************************/
function openEditor()
{
if(recordValues["tableName"])
{
setModalOpen(true);
}
}
/*******************************************************************************
**
*******************************************************************************/
function saveClicked()
{
if(!onSaveCallback)
{
console.log("onSaveCallback was not defined");
return;
}
// @ts-ignore possibly 'undefined'.
const view = recordQueryRef?.current?.getCurrentView();
view.queryColumns.sortColumnsFixingPinPositions();
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// keep the query filter that came from the recordQuery screen as the front-end version (w/ possible value objects) //
// but prep a copy of it for the backend, to stringify as json in the record being edited //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setFrontendQueryFilter(view.queryFilter);
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)});
closeEditor();
}
/*******************************************************************************
**
*******************************************************************************/
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
{
if(reason == "backdropClick" || reason == "escapeKeyDown")
{
return;
}
setModalOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function renderColumn(column: Column): JSX.Element
{
const [field, table] = FilterUtils.getField(tableMetaData, column.name)
if(!column || !column.isVisible || column.name == "__check__" || !field)
{
return (<React.Fragment />);
}
const tableLabelPart = table.name != tableMetaData.name ? table.label + ": " : "";
return (<Box mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">
{tableLabelPart}{field.label}
</Box>);
}
/*******************************************************************************
**
*******************************************************************************/
function mayShowQueryPreview(): boolean
{
if(tableMetaData)
{
if(frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
{
return (true);
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function mayShowColumnsPreview(): boolean
{
if(tableMetaData)
{
for(let i = 0; i<columns?.columns?.length; i++)
{
if(columns.columns[i].isVisible && columns.columns[i].name != "__check__")
{
return (true);
}
}
}
return (false);
}
const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
/*******************************************************************************
**
*******************************************************************************/
function showHelp(slot: string)
{
return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles));
}
/*******************************************************************************
**
*******************************************************************************/
function getHelpContent(slot: string)
{
const key = `widget:${widgetMetaData.name};slot:${slot}`;
return <HelpContent helpContents={widgetMetaData?.helpContent?.get(slot)} roles={helpRoles} helpContentKey={key} />;
}
/////////////////////////////////////////////////
// add link to widget header for opening modal //
/////////////////////////////////////////////////
const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up your report filters and columns";
const labelAdditionalElementsRight: JSX.Element[] = []
if(isEditable)
{
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent label="Edit Filters and Columns" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />)
}
return (<Widget widgetMetaData={widgetMetaData} labelAdditionalElementsRight={labelAdditionalElementsRight}>
<React.Fragment>
{
showHelp("sectionSubhead") &&
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"}>
{getHelpContent("sectionSubhead")}
</Box>
}
<Collapse in={Boolean(alertContent)}>
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}} onClose={() => setAlertContent(null)}>{alertContent}</Alert>
</Collapse>
<Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center">
<h5>Query Filter</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQueryPreview() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box>
{
mayShowQueryPreview() &&
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} />
}
{
!mayShowQueryPreview() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} borderRadius="0.75rem" border={`1px solid ${colors.grayLines.main}`}>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={{mb: "0.125rem", ...unborderedButtonSX}} onClick={openEditor}>+ Add Filters</Button></span>
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>Your report has no filters.</Box>
}
</Box>
}
</Box>
<Box pt="1rem">
<h5>Columns</h5>
<Box display="flex" flexWrap="wrap" fontSize="1rem">
{
mayShowColumnsPreview() &&
columns.columns.map((column, i) => <React.Fragment key={i}>{renderColumn(column)}</React.Fragment>)
}
{
!mayShowColumnsPreview() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={unborderedButtonSX} onClick={openEditor}>+ Add Columns</Button></span>
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>Your report has no columns.</Box>
}
</Box>
}
</Box>
</Box>
{
modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>
<div>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{m: "2rem", p: "2rem"}}>
<h3>Edit Filters and Columns</h3>
{
showHelp("modalSubheader") &&
<Box color={colors.gray.main} pb={"0.5rem"}>
{getHelpContent("modalSubheader")}
</Box>
}
{
tableMetaData && <RecordQuery
ref={recordQueryRef}
table={tableMetaData}
usage="reportSetup"
isModal={true}
initialQueryFilter={usingDefaultEmptyFilter ? null : frontendQueryFilter}
initialColumns={columns}
/>
}
<Box>
<Box display="flex" justifyContent="flex-end">
<QCancelButton disabled={false} onClickHandler={closeEditor} />
<QSaveButton label="OK" iconName="check" disabled={false} onClickHandler={saveClicked} />
</Box>
</Box>
</Card>
</Box>
</div>
</Modal>
}
</React.Fragment>
</Widget>);
}

View File

@ -169,7 +169,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
{ {
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "404") if ((e as QException).status === 404)
{ {
setNotFoundMessage("Script code could not be found."); setNotFoundMessage("Script code could not be found.");
return; return;

View File

@ -21,8 +21,8 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {Theme} from "@mui/material/styles"; import {Theme} from "@mui/material/styles";
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {ReactNode} from "react";
// Declaring prop types for DataTableBodyCell // Declaring prop types for DataTableBodyCell
interface Props interface Props
@ -49,7 +49,7 @@ function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
"@media (max-width: 1440px)": { "@media (max-width: 1440px)": {
fontSize: "0.875rem" fontSize: "0.875rem"
}, },
"&:nth-child(1)": { "&:nth-of-type(1)": {
paddingLeft: "1rem" paddingLeft: "1rem"
}, },
"&:last-child": { "&:last-child": {

View File

@ -23,9 +23,9 @@ import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import {Theme} from "@mui/material/styles"; import {Theme} from "@mui/material/styles";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import {ReactNode} from "react";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {useMaterialUIController} from "qqq/context"; import {useMaterialUIController} from "qqq/context";
import {ReactNode} from "react";
// Declaring props types for DataTableHeadCell // Declaring props types for DataTableHeadCell
interface Props interface Props
@ -50,7 +50,7 @@ function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: P
px={1.5} px={1.5}
sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({ sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({
borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`, borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`,
"&:nth-child(1)": { "&:nth-of-type(1)": {
paddingLeft: "1rem" paddingLeft: "1rem"
}, },
"&:last-child": { "&:last-child": {

View File

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

View File

@ -0,0 +1,159 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
/*******************************************************************************
** put a unique key value in all the pivot table group-by and value objects,
** to help react rendering be sane.
*******************************************************************************/
export class PivotObjectKey
{
private static value = new Date().getTime();
static next(): number
{
return PivotObjectKey.value++;
}
}
/*******************************************************************************
** Full definition of pivot table
*******************************************************************************/
export class PivotTableDefinition
{
rows: PivotTableGroupBy[];
columns: PivotTableGroupBy[];
values: PivotTableValue[];
}
/*******************************************************************************
** A field that the pivot table is grouped by, either as a row or column
*******************************************************************************/
export class PivotTableGroupBy
{
fieldName: string;
key: number;
constructor()
{
this.key = PivotObjectKey.next();
}
}
/*******************************************************************************
** A field & function that serves as the computed values in the pivot table
*******************************************************************************/
export class PivotTableValue
{
fieldName: string;
function: PivotTableFunction;
key: number;
constructor()
{
this.key = PivotObjectKey.next();
}
}
/*******************************************************************************
** Functions that can be applied to pivot table values
*******************************************************************************/
export enum PivotTableFunction
{
SUM = "SUM",
COUNT = "COUNT",
AVERAGE = "AVERAGE",
MAX = "MAX",
MIN = "MIN",
PRODUCT = "PRODUCT",
///////////////////////////////////////////////////////////////////////////////
// i don't think we have a useful version of count-nums --unless we allowed //
// it on string fields, and counted if they looked like numbers? is that //
// what we should do? ... leave here as zombie in case that request comes in //
///////////////////////////////////////////////////////////////////////////////
// COUNT_NUMS = "COUNT_NUMS",
STD_DEV = "STD_DEV",
STD_DEVP = "STD_DEVP",
VAR = "VAR",
VARP = "VARP",
}
const allFunctions = [
PivotTableFunction.SUM,
PivotTableFunction.COUNT,
PivotTableFunction.AVERAGE,
PivotTableFunction.MAX,
PivotTableFunction.MIN,
PivotTableFunction.PRODUCT,
// PivotTableFunction.COUNT_NUMS,
PivotTableFunction.STD_DEV,
PivotTableFunction.STD_DEVP,
PivotTableFunction.VAR,
PivotTableFunction.VARP
];
const onlyCount = [PivotTableFunction.COUNT];
const functionsForDates = [PivotTableFunction.COUNT, PivotTableFunction.AVERAGE, PivotTableFunction.MAX, PivotTableFunction.MIN];
export const functionsPerFieldType: { [type: string]: PivotTableFunction[] } = {};
functionsPerFieldType[QFieldType.STRING] = onlyCount;
functionsPerFieldType[QFieldType.BOOLEAN] = onlyCount;
functionsPerFieldType[QFieldType.BLOB] = onlyCount;
functionsPerFieldType[QFieldType.HTML] = onlyCount;
functionsPerFieldType[QFieldType.PASSWORD] = onlyCount;
functionsPerFieldType[QFieldType.TEXT] = onlyCount;
functionsPerFieldType[QFieldType.TIME] = onlyCount;
functionsPerFieldType[QFieldType.INTEGER] = allFunctions;
functionsPerFieldType[QFieldType.DECIMAL] = allFunctions;
// functionsPerFieldType[QFieldType.LONG] = allFunctions;
functionsPerFieldType[QFieldType.DATE] = functionsForDates;
functionsPerFieldType[QFieldType.DATE_TIME] = functionsForDates;
//////////////////////////////////////
// labels for pivot table functions //
//////////////////////////////////////
export const pivotTableFunctionLabels =
{
"SUM": "Sum",
"COUNT": "Count",
"AVERAGE": "Average",
"MAX": "Max",
"MIN": "Min",
"PRODUCT": "Product",
// "COUNT_NUMS": "Count Numbers",
"STD_DEV": "StdDev",
"STD_DEVP": "StdDevp",
"VAR": "Var",
"VARP": "Varp"
};

View File

@ -23,14 +23,13 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {GridPinnedColumns} from "@mui/x-data-grid-pro"; import {GridPinnedColumns} from "@mui/x-data-grid-pro";
import quickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import DataGridUtils from "qqq/utils/DataGridUtils"; import DataGridUtils from "qqq/utils/DataGridUtils";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
/******************************************************************************* /*******************************************************************************
** member object ** member object
*******************************************************************************/ *******************************************************************************/
interface Column export interface Column
{ {
name: string; name: string;
isVisible: boolean; isVisible: boolean;
@ -81,11 +80,19 @@ export default class QQueryColumns
fields.forEach((field) => fields.forEach((field) =>
{ {
const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)}; const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)};
queryColumns.columns.push(column);
if (field.name == table.primaryKeyField) if (field.name == table.primaryKeyField)
{ {
column.pinned = "left"; column.pinned = "left";
//////////////////////////////////////////////////
// insert the primary key field after __check__ //
//////////////////////////////////////////////////
queryColumns.columns.splice(1, 0, column);
}
else
{
queryColumns.columns.push(column);
} }
}); });
@ -393,6 +400,42 @@ export default class QQueryColumns
return columnVisibilityModel; return columnVisibilityModel;
}; };
/*******************************************************************************
** sort the columns list, so that pinned columns go to the front (left) or back
** (right) of the list.
*******************************************************************************/
public sortColumnsFixingPinPositions = (): void =>
{
/////////////////////////////////////////////////////////////////////////////////////////////
// do a sort to push pinned-left columns to the start, and pinned-right columns to the end //
// and otherwise, leave everything alone //
/////////////////////////////////////////////////////////////////////////////////////////////
this.columns = this.columns.sort((a: Column, b: Column) =>
{
if(a.pinned == "left" && b.pinned != "left")
{
return -1;
}
else if(b.pinned == "left" && a.pinned != "left")
{
return 1;
}
else if(a.pinned == "right" && b.pinned != "right")
{
return 1;
}
else if(b.pinned == "right" && a.pinned != "right")
{
return -1;
}
else
{
return (0);
}
});
}
} }

View File

@ -31,8 +31,6 @@ import Box from "@mui/material/Box";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import React, {useContext, useEffect, useState} from "react";
import {Link, useLocation} from "react-router-dom";
import QContext from "QContext"; import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
@ -41,6 +39,8 @@ import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
import MiniStatisticsCard from "qqq/components/widgets/statistics/MiniStatisticsCard"; import MiniStatisticsCard from "qqq/components/widgets/statistics/MiniStatisticsCard";
import BaseLayout from "qqq/layouts/BaseLayout"; import BaseLayout from "qqq/layouts/BaseLayout";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import React, {useContext, useEffect, useState} from "react";
import {Link, useLocation} from "react-router-dom";
const qController = Client.getInstance(); const qController = Client.getInstance();
@ -62,7 +62,7 @@ function AppHome({app}: Props): JSX.Element
const [updatedTableCounts, setUpdatedTableCounts] = useState(new Date()); const [updatedTableCounts, setUpdatedTableCounts] = useState(new Date());
const [widgets, setWidgets] = useState([] as any[]); const [widgets, setWidgets] = useState([] as any[]);
const {pageHeader, setPageHeader} = useContext(QContext); const {pageHeader, recordAnalytics, setPageHeader} = useContext(QContext);
const location = useLocation(); const location = useLocation();
@ -86,8 +86,9 @@ function AppHome({app}: Props): JSX.Element
useEffect(() => useEffect(() =>
{ {
// setPageHeader(app.label);
setPageHeader(null); setPageHeader(null);
recordAnalytics({location: window.location, title: "App: " + app.label});
recordAnalytics({category: "appEvents", action: "loadAppScreen", label: app.label});
if (!qInstance) if (!qInstance)
{ {

View File

@ -91,7 +91,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const processNameParam = useParams().processName; const processNameParam = useParams().processName;
const processName = process === null ? processNameParam : process.name; const processName = process === null ? processNameParam : process.name;
let tableVariantLocalStorageKey: string | null = null; let tableVariantLocalStorageKey: string | null = null;
if(table) if (table)
{ {
tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${table.name}`; tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${table.name}`;
} }
@ -124,7 +124,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const [showErrorDetail, setShowErrorDetail] = useState(false); const [showErrorDetail, setShowErrorDetail] = useState(false);
const [showFullHelpText, setShowFullHelpText] = useState(false); const [showFullHelpText, setShowFullHelpText] = useState(false);
const {pageHeader, setPageHeader} = useContext(QContext); const {pageHeader, recordAnalytics, setPageHeader} = useContext(QContext);
////////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for setting the processError state - call this function, which will also set the isUserFacingError state // // for setting the processError state - call this function, which will also set the isUserFacingError state //
@ -427,10 +427,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////// //////////////////////////////////////////////////
step.components && (step.components.map((component: QFrontendComponent, index: number) => step.components && (step.components.map((component: QFrontendComponent, index: number) =>
{ {
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"] let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
if(component.type == QComponentType.BULK_EDIT_FORM) if (component.type == QComponentType.BULK_EDIT_FORM)
{ {
helpRoles = ["EDIT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] helpRoles = ["EDIT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"];
} }
return ( return (
@ -1079,7 +1079,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const handlePermissionDenied = (e: any): boolean => const handlePermissionDenied = (e: any): boolean =>
{ {
if ((e as QException).status === "403") if ((e as QException).status === 403)
{ {
setProcessError(`You do not have permission to run this ${isReport ? "report" : "process"}.`, true); setProcessError(`You do not have permission to run this ${isReport ? "report" : "process"}.`, true);
return (true); return (true);
@ -1157,6 +1157,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const processMetaData = await Client.getInstance().loadProcessMetaData(processName); const processMetaData = await Client.getInstance().loadProcessMetaData(processName);
setProcessMetaData(processMetaData); setProcessMetaData(processMetaData);
setSteps(processMetaData.frontendSteps); setSteps(processMetaData.frontendSteps);
recordAnalytics({location: window.location, title: "Process: " + processMetaData?.label});
recordAnalytics({category: "processEvents", action: "startProcess", label: processMetaData?.label});
if (processMetaData.tableName && !tableMetaData) if (processMetaData.tableName && !tableMetaData)
{ {
try try
@ -1262,6 +1266,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setTimeout(async () => setTimeout(async () =>
{ {
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
const processResponse = await Client.getInstance().processStep( const processResponse = await Client.getInstance().processStep(
processName, processName,
processUUID, processUUID,

View File

@ -76,7 +76,7 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import React, {forwardRef, useContext, useEffect, useImperativeHandle, useReducer, useRef, useState} from "react";
import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId"; const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId";
@ -84,18 +84,18 @@ const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density";
const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView"; const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView";
export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
export type QueryScreenUsage = "queryScreen" | "reportSetup"
interface Props interface Props
{ {
table?: QTableMetaData; table?: QTableMetaData;
launchProcess?: QProcessMetaData; launchProcess?: QProcessMetaData;
usage?: QueryScreenUsage;
isModal?: boolean;
initialQueryFilter?: QQueryFilter;
initialColumns?: QQueryColumns;
} }
RecordQuery.defaultProps = {
table: null,
launchProcess: null
};
/////////////////////////////////////////////////////// ///////////////////////////////////////////////////////
// define possible values for our pageState variable // // define possible values for our pageState variable //
/////////////////////////////////////////////////////// ///////////////////////////////////////////////////////
@ -107,8 +107,13 @@ const qController = Client.getInstance();
** function to produce standard version of the screen while we're "loading" ** function to produce standard version of the screen while we're "loading"
** like the main table meta data etc. ** like the main table meta data etc.
*******************************************************************************/ *******************************************************************************/
const getLoadingScreen = () => const getLoadingScreen = (isModal: boolean) =>
{ {
if(isModal)
{
return (<Box>&nbsp;</Box>);
}
return (<BaseLayout> return (<BaseLayout>
&nbsp; &nbsp;
</BaseLayout>); </BaseLayout>);
@ -120,7 +125,7 @@ const getLoadingScreen = () =>
** **
** Yuge component. The best. Lots of very smart people are saying so. ** Yuge component. The best. Lots of very smart people are saying so.
*******************************************************************************/ *******************************************************************************/
function RecordQuery({table, launchProcess}: Props): JSX.Element const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, initialColumns}: Props, ref) =>
{ {
const tableName = table.name; const tableName = table.name;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -136,6 +141,44 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const [firstRender, setFirstRender] = useState(true); const [firstRender, setFirstRender] = useState(true);
const [isFirstRenderAfterChangingTables, setIsFirstRenderAfterChangingTables] = useState(false); const [isFirstRenderAfterChangingTables, setIsFirstRenderAfterChangingTables] = useState(false);
const [loadedFilterFromInitialFilterParam, setLoadedFilterFromInitialFilterParam] = useState(false);
const mayWriteLocalStorage = usage == "queryScreen";
/*******************************************************************************
**
*******************************************************************************/
function localStorageSet(key: string, value: string)
{
if(mayWriteLocalStorage)
{
localStorage.setItem(key, value);
}
}
/*******************************************************************************
**
*******************************************************************************/
function localStorageRemove(key: string)
{
if(mayWriteLocalStorage)
{
localStorage.removeItem(key);
}
}
useImperativeHandle(ref, () =>
{
return {
getCurrentView(): RecordQueryView
{
return view;
}
}
});
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting // // manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -180,7 +223,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
///////////////////////////////////// /////////////////////////////////////
const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`; const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`;
// only load things out of local storage on the first render ///////////////////////////////////////////////////////////////
// only load things out of local storage on the first render //
///////////////////////////////////////////////////////////////
if (firstRender) if (firstRender)
{ {
console.log("This is firstRender, so reading defaults from local storage..."); console.log("This is firstRender, so reading defaults from local storage...");
@ -211,6 +256,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
defaultView.mode = defaultMode; defaultView.mode = defaultMode;
} }
if(firstRender)
{
/////////////////////////////////////////////////////////////////////////
// allow a caller to send in an initial filter & set of columns. //
// only to be used on "first render". //
// JSON.parse(JSON.stringify()) to do deep clone and keep object clean //
// unclear why not needed on initialColumns... //
/////////////////////////////////////////////////////////////////////////
if (initialQueryFilter)
{
defaultView.queryFilter = JSON.parse(JSON.stringify(initialQueryFilter));
setLoadedFilterFromInitialFilterParam(true);
}
if (initialColumns)
{
defaultView.queryColumns = initialColumns;
}
}
///////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////
// in case the view is missing any of these attributes, give them a reasonable default // // in case the view is missing any of these attributes, give them a reasonable default //
///////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////
@ -338,7 +403,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
///////////////////////////// /////////////////////////////
// page context references // // page context references //
///////////////////////////// /////////////////////////////
const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); const {accentColor, accentColorLight, setPageHeader, recordAnalytics, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
// we use our own header - so clear out the context page header // // we use our own header - so clear out the context page header //
@ -418,51 +483,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}; };
/*******************************************************************************
**
*******************************************************************************/
const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) =>
{
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.subFilters, sourceFilter.booleanOperator);
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
{
const criteria = sourceFilter.criteria[i];
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
///////////////////////////////////////////////////////////////////////////////////////////
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
///////////////////////////////////////////////////////////////////////////////////////////
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName);
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
}
}
}
/////////////////////////////////////////
// recursively prep subfilters as well //
/////////////////////////////////////////
let subFilters = [] as QQueryFilter[];
for (let j = 0; j < sourceFilter?.subFilters?.length; j++)
{
subFilters.push(prepQueryFilterForBackend(sourceFilter.subFilters[j]));
}
filterForBackend.subFilters = subFilters;
filterForBackend.skip = pageNumber * rowsPerPage;
filterForBackend.limit = rowsPerPage;
return filterForBackend;
};
/******************************************************************************* /*******************************************************************************
** **
@ -494,7 +514,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
totalRecords: totalRecords, totalRecords: totalRecords,
columnsModel: columnsModel, columnsModel: columnsModel,
columnVisibilityModel: columnVisibilityModel, columnVisibilityModel: columnVisibilityModel,
queryFilter: prepQueryFilterForBackend(queryFilter) queryFilter: FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter)
}; };
exportMenu = (<> exportMenu = (<>
@ -610,25 +630,25 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess) if (validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess)
{ {
if (!e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
{ {
e.preventDefault(); e.preventDefault();
navigate(`${metaData?.getTablePathByName(tableName)}/create`); navigate(`${metaData?.getTablePathByName(tableName)}/create`);
} }
else if (!e.metaKey && e.key === "r") else if (!e.metaKey && !e.ctrlKey && e.key === "r")
{ {
e.preventDefault(); e.preventDefault();
updateTable("'r' keyboard event"); updateTable("'r' keyboard event");
} }
/* /*
// disable until we add a ... ref down to let us programmatically open Columns button // disable until we add a ... ref down to let us programmatically open Columns button
else if (! e.metaKey && e.key === "c") else if (! e.metaKey && !e.ctrlKey && e.key === "c")
{ {
e.preventDefault() e.preventDefault()
gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns) gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns)
} }
*/ */
else if (!e.metaKey && e.key === "f") else if (!e.metaKey && !e.ctrlKey && e.key === "f")
{ {
e.preventDefault(); e.preventDefault();
@ -688,8 +708,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (localStorage.getItem(currentSavedViewLocalStorageKey)) if (localStorage.getItem(currentSavedViewLocalStorageKey))
{ {
currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); if(usage == "queryScreen")
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); {
currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
}
} }
else else
{ {
@ -726,7 +749,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter) FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter)
} }
localStorage.setItem(viewLocalStorageKey, JSON.stringify(viewForLocalStorage)); localStorageSet(viewLocalStorageKey, JSON.stringify(viewForLocalStorage));
} }
catch(e) catch(e)
{ {
@ -861,7 +884,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/******************************************************************************* /*******************************************************************************
** This is the method that actually executes a query to update the data in the table. ** This is the method that actually executes a query to update the data in the table.
*******************************************************************************/ *******************************************************************************/
const updateTable = (reason?: string) => const updateTable = (reason?: string, clearOutCount = true) =>
{ {
if (pageState != "ready") if (pageState != "ready")
{ {
@ -875,6 +898,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return; return;
} }
recordAnalytics({category: "tableEvents", action: "query", label: tableMetaData.label});
console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`); console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`);
setLoading(true); setLoading(true);
setRows([]); setRows([]);
@ -885,7 +910,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// copy the orderBys & operator into it - but we'll build its criteria one-by-one, // // copy the orderBys & operator into it - but we'll build its criteria one-by-one, //
// as clones, as we'll need to tweak them a bit // // as clones, as we'll need to tweak them a bit //
///////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////
const filterForBackend = prepQueryFilterForBackend(queryFilter); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter, pageNumber, rowsPerPage);
////////////////////////////////////////// //////////////////////////////////////////
// figure out joins to use in the query // // figure out joins to use in the query //
@ -911,6 +936,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
console.log(`Issuing query: ${thisQueryId}`); console.log(`Issuing query: ${thisQueryId}`);
if (tableMetaData.capabilities.has(Capability.TABLE_COUNT)) if (tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{ {
if(clearOutCount)
{
setTotalRecords(null);
setDistinctRecords(null);
}
let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables()); let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables());
qController.count(tableName, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => qController.count(tableName, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) =>
{ {
@ -943,7 +974,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
console.log(`Received error for query ${thisQueryId}`); console.log(`Received error for query ${thisQueryId}`);
console.log(error); console.log(error);
var errorMessage; let errorMessage;
if (error && error.message) if (error && error.message)
{ {
errorMessage = error.message; errorMessage = error.message;
@ -1108,7 +1139,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (state && state.density && state.density.value !== density) if (state && state.density && state.density.value !== density)
{ {
setDensity(state.density.value); setDensity(state.density.value);
localStorage.setItem(densityLocalStorageKey, JSON.stringify(state.density.value)); localStorageSet(densityLocalStorageKey, JSON.stringify(state.density.value));
} }
}; };
@ -1412,7 +1443,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (selectFullFilterState === "filter") if (selectFullFilterState === "filter")
{ {
const filterForBackend = prepQueryFilterForBackend(queryFilter); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter);
filterForBackend.skip = 0; filterForBackend.skip = 0;
filterForBackend.limit = null; filterForBackend.limit = null;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`; return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
@ -1420,7 +1451,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (selectFullFilterState === "filterSubset") if (selectFullFilterState === "filterSubset")
{ {
const filterForBackend = prepQueryFilterForBackend(queryFilter); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter);
filterForBackend.skip = 0; filterForBackend.skip = 0;
filterForBackend.limit = selectionSubsetSize; filterForBackend.limit = selectionSubsetSize;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`; return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
@ -1443,14 +1474,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (selectFullFilterState === "filter") if (selectFullFilterState === "filter")
{ {
const filterForBackend = prepQueryFilterForBackend(queryFilter); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter);
filterForBackend.skip = 0; filterForBackend.skip = 0;
filterForBackend.limit = null; filterForBackend.limit = null;
setRecordIdsForProcess(filterForBackend); setRecordIdsForProcess(filterForBackend);
} }
else if (selectFullFilterState === "filterSubset") else if (selectFullFilterState === "filterSubset")
{ {
const filterForBackend = prepQueryFilterForBackend(queryFilter); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter);
filterForBackend.skip = 0; filterForBackend.skip = 0;
filterForBackend.limit = selectionSubsetSize; filterForBackend.limit = selectionSubsetSize;
setRecordIdsForProcess(filterForBackend); setRecordIdsForProcess(filterForBackend);
@ -1605,7 +1636,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
//////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////
// todo can/should/does this move into the view's "identity"? // // todo can/should/does this move into the view's "identity"? //
//////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////
localStorage.setItem(currentSavedViewLocalStorageKey, `${savedViewRecord.values.get("id")}`); localStorageSet(currentSavedViewLocalStorageKey, `${savedViewRecord.values.get("id")}`);
}; };
@ -1615,7 +1646,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const doClearCurrentSavedView = () => const doClearCurrentSavedView = () =>
{ {
setCurrentSavedView(null); setCurrentSavedView(null);
localStorage.removeItem(currentSavedViewLocalStorageKey); localStorageRemove(currentSavedViewLocalStorageKey);
}; };
@ -1642,6 +1673,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
if (selectedSavedViewId != null) if (selectedSavedViewId != null)
{ {
recordAnalytics({category: "tableEvents", action: "activateSavedView", label: tableMetaData.label});
////////////////////////////////////////////// //////////////////////////////////////////////
// fetch, then activate the selected filter // // fetch, then activate the selected filter //
////////////////////////////////////////////// //////////////////////////////////////////////
@ -1657,12 +1690,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
///////////////////////////////// /////////////////////////////////
// this is 'new view' - right? // // this is 'new view' - right? //
///////////////////////////////// /////////////////////////////////
recordAnalytics({category: "tableEvents", action: "activateNewView", label: tableMetaData.label});
////////////////////////////// //////////////////////////////
// wipe away the saved view // // wipe away the saved view //
////////////////////////////// //////////////////////////////
setCurrentSavedView(null); setCurrentSavedView(null);
localStorage.removeItem(currentSavedViewLocalStorageKey); localStorageRemove(currentSavedViewLocalStorageKey);
/////////////////////////////////////////////// ///////////////////////////////////////////////
// activate a new default view for the table // // activate a new default view for the table //
@ -1908,7 +1942,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
*******************************************************************************/ *******************************************************************************/
const openColumnStatistics = async (column: GridColDef) => const openColumnStatistics = async (column: GridColDef) =>
{ {
setFilterForColumnStats(prepQueryFilterForBackend(queryFilter)); setFilterForColumnStats(FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter));
setColumnStatsFieldName(column.field); setColumnStatsFieldName(column.field);
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field); const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field);
@ -2183,27 +2217,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<GridToolbarDensitySelector nonce={undefined} /> <GridToolbarDensitySelector nonce={undefined} />
</div> </div>
<div style={{zIndex: 10}}> {
<MenuButton label="Selection" iconName={selectedIds.length == 0 ? "check_box_outline_blank" : "check_box"} disabled={totalRecords == 0} options={selectionMenuOptions} callback={selectionMenuCallback} /> usage == "queryScreen" &&
<SelectionSubsetDialog isOpen={selectionSubsetSizePromptOpen} initialValue={selectionSubsetSize} closeHandler={(value) => <div style={{zIndex: 10}}>
{ <MenuButton label="Selection" iconName={selectedIds.length == 0 ? "check_box_outline_blank" : "check_box"} disabled={totalRecords == 0} options={selectionMenuOptions} callback={selectionMenuCallback} />
setSelectionSubsetSizePromptOpen(false); <SelectionSubsetDialog isOpen={selectionSubsetSizePromptOpen} initialValue={selectionSubsetSize} closeHandler={(value) =>
if (value !== undefined)
{ {
if (typeof value === "number" && value > 0) setSelectionSubsetSizePromptOpen(false);
if (value !== undefined)
{ {
programmaticallySelectSomeOrAllRows(value); if (typeof value === "number" && value > 0)
setSelectionSubsetSize(value); {
setSelectFullFilterState("filterSubset"); programmaticallySelectSomeOrAllRows(value);
setSelectionSubsetSize(value);
setSelectFullFilterState("filterSubset");
}
else
{
setAlertContent("Unexpected value: " + value);
}
} }
else }} />
{ </div>
setAlertContent("Unexpected value: " + value); }
}
}
}} />
</div>
<div> <div>
{ {
@ -2266,7 +2303,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// to avoid both this useEffect and the one below from both doing an "initial query", // // to avoid both this useEffect and the one below from both doing an "initial query", //
// only run this one if at least 1 query has already been ran // // only run this one if at least 1 query has already been ran //
//////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////
updateTable("useEffect(pageNumber,rowsPerPage)"); updateTable("useEffect(pageNumber,rowsPerPage)", false);
} }
}, [pageNumber, rowsPerPage]); }, [pageNumber, rowsPerPage]);
@ -2301,7 +2338,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (pageState == "ready") if (pageState == "ready")
{ {
const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter);
///////////////////////////////////////////////////////////////////////
// remove the skip & limit (e.g., pagination) from this hash - //
// as we have a specific useEffect watching these, specifically //
// so we can pass the dont-clear-count flag into updateTable, //
// to try to keep the count from flashing back & forth to "Counting" //
///////////////////////////////////////////////////////////////////////
filterForBackend.skip = null;
filterForBackend.limit = null;
const newFilterHash = JSON.stringify(filterForBackend);
if (filterHash != newFilterHash) if (filterHash != newFilterHash)
{ {
setFilterHash(newFilterHash); setFilterHash(newFilterHash);
@ -2327,6 +2375,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
setTableLabel(tableMetaData.label); setTableLabel(tableMetaData.label);
recordAnalytics({location: window.location, title: "Query: " + tableMetaData.label});
setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown
setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks) setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks)
@ -2470,11 +2520,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// if the last time we were on this table, a currentSavedView was written to local storage - // // if the last time we were on this table, a currentSavedView was written to local storage - //
// then navigate back to that view's URL - unless - it looks like we're on a process! // // then navigate back to that view's URL - unless - it looks like we're on a process! //
/////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////
if (localStorage.getItem(currentSavedViewLocalStorageKey) && !urlLooksLikeProcess()) if (localStorage.getItem(currentSavedViewLocalStorageKey) && !urlLooksLikeProcess() && !loadedFilterFromInitialFilterParam)
{ {
const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
console.log(`returning to previously active saved view ${currentSavedViewId}`); console.log(`returning to previously active saved view ${currentSavedViewId}`);
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); if(usage == "queryScreen")
{
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
}
setViewIdInLocation(currentSavedViewId); setViewIdInLocation(currentSavedViewId);
///////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////
@ -2530,7 +2583,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
promptForTableVariantSelection(); promptForTableVariantSelection();
} }
return (getLoadingScreen()); return (getLoadingScreen(isModal));
} }
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
@ -2564,7 +2617,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setRows([]); setRows([]);
setIsFirstRenderAfterChangingTables(true); setIsFirstRenderAfterChangingTables(true);
return (getLoadingScreen()); return (getLoadingScreen(isModal));
} }
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
@ -2608,7 +2661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (pageState != "ready") if (pageState != "ready")
{ {
console.log(`page state is ${pageState}... no-op while those complete async's run...`); console.log(`page state is ${pageState}... no-op while those complete async's run...`);
return (getLoadingScreen()); return (getLoadingScreen(isModal));
} }
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
@ -2617,13 +2670,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
if (!tableMetaData) if (!tableMetaData)
{ {
return (getLoadingScreen()); return (getLoadingScreen(isModal));
} }
let savedViewsComponent = null; let savedViewsComponent = null;
if (metaData && metaData.processes.has("querySavedView")) if (metaData && metaData.processes.has("querySavedView"))
{ {
savedViewsComponent = (<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} tableDefaultView={tableDefaultView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} />); savedViewsComponent = (<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} tableDefaultView={tableDefaultView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} queryScreenUsage={usage} />);
} }
@ -2700,7 +2753,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}; };
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
// these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it // // these numbers help set the height of the grid (so page won't scroll) based on space above & below it //
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
let spaceBelowGrid = 40; let spaceBelowGrid = 40;
let spaceAboveGrid = 205; let spaceAboveGrid = 205;
@ -2714,40 +2767,48 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
spaceAboveGrid += 60; spaceAboveGrid += 60;
} }
if(isModal)
{
spaceAboveGrid += 130;
}
//////////////////////// ////////////////////////
// main screen render // // main screen render //
//////////////////////// ////////////////////////
return ( const body = (
<BaseLayout> <React.Fragment>
<Box display="flex" justifyContent="space-between"> <Box display="flex" justifyContent="space-between">
<Box> <Box>
<Typography textTransform="capitalize" variant="h3"> <Typography textTransform="capitalize" variant="h3">
{pageLoadingState.isLoading() && ""} {pageLoadingState.isLoading() && ""}
{pageLoadingState.isLoadingSlow() && "Loading..."} {pageLoadingState.isLoadingSlow() && "Loading..."}
{pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} {pageLoadingState.isNotLoading() && !isModal && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)}
</Typography> </Typography>
</Box> </Box>
<Box whiteSpace="nowrap"> {
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} /> !isModal &&
<Box display="inline-block" width="150px"> <Box whiteSpace="nowrap">
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
<Box display="inline-block" width="150px">
{
tableMetaData &&
<QueryScreenActionMenu
metaData={metaData}
tableMetaData={tableMetaData}
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>
}
</Box>
{ {
tableMetaData && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QueryScreenActionMenu <QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
metaData={metaData}
tableMetaData={tableMetaData}
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>
} }
</Box> </Box>
{ }
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
</Box> </Box>
<div className="recordQuery"> <div className="recordQuery">
{/* {/*
@ -2801,6 +2862,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setQuickFilterFieldNames={doSetQuickFilterFieldNames} setQuickFilterFieldNames={doSetQuickFilterFieldNames}
gridApiRef={gridApiRef} gridApiRef={gridApiRef}
mode={mode} mode={mode}
queryScreenUsage={usage}
setMode={doSetMode} setMode={doSetMode}
savedViewsComponent={savedViewsComponent} savedViewsComponent={savedViewsComponent}
columnMenuComponent={buildColumnMenu()} columnMenuComponent={buildColumnMenu()}
@ -2841,7 +2903,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
sortingMode="server" sortingMode="server"
filterMode="server" filterMode="server"
page={pageNumber} page={pageNumber}
checkboxSelection checkboxSelection={usage == "queryScreen"}
disableSelectionOnClick disableSelectionOnClick
autoHeight={false} autoHeight={false}
rows={rows} rows={rows}
@ -2850,7 +2912,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
rowBuffer={10} rowBuffer={10}
rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords} rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords}
onPageSizeChange={handleRowsPerPageChange} onPageSizeChange={handleRowsPerPageChange}
onRowClick={handleRowClick} onRowClick={usage == "queryScreen" ? handleRowClick : null}
onStateChange={handleStateChange} onStateChange={handleStateChange}
density={density} density={density}
loading={loading} loading={loading}
@ -2908,8 +2970,28 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
</Modal> </Modal>
} }
</div> </div>
</BaseLayout> </React.Fragment>
); );
}
if(isModal)
{
return body;
}
return (
<BaseLayout>{body}</BaseLayout>
)
})
RecordQuery.defaultProps = {
table: null,
usage: "queryScreen",
launchProcess: null,
isModal: false,
initialQueryFilter: null,
initialColumns: null,
};
export default RecordQuery; export default RecordQuery;

View File

@ -28,9 +28,6 @@ import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import Snackbar from "@mui/material/Snackbar"; import Snackbar from "@mui/material/Snackbar";
import React, {useContext, useReducer, useState} from "react";
import AceEditor from "react-ace";
import {useParams} from "react-router-dom";
import QContext from "QContext"; import QContext from "QContext";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer"; import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import BaseLayout from "qqq/layouts/BaseLayout"; import BaseLayout from "qqq/layouts/BaseLayout";
@ -41,6 +38,9 @@ import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript"; import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json"; import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github"; import "ace-builds/src-noconflict/theme-github";
import React, {useContext, useReducer, useState} from "react";
import AceEditor from "react-ace";
import {useParams} from "react-router-dom";
import "ace-builds/src-noconflict/ext-language_tools"; import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance(); const qController = Client.getInstance();
@ -69,13 +69,9 @@ function RecordDeveloperView({table}: Props): JSX.Element
const [associatedScripts, setAssociatedScripts] = useState([] as any[]); const [associatedScripts, setAssociatedScripts] = useState([] as any[]);
const [notFoundMessage, setNotFoundMessage] = useState(null); const [notFoundMessage, setNotFoundMessage] = useState(null);
const [selectedTabs, setSelectedTabs] = useState({} as any);
const [viewingRevisions, setViewingRevisions] = useState({} as any);
const [scriptLogs, setScriptLogs] = useState({} as any);
const [alertText, setAlertText] = useState(null as string); const [alertText, setAlertText] = useState(null as string);
const {setPageHeader} = useContext(QContext); const {setPageHeader, recordAnalytics} = useContext(QContext);
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
if (!asyncLoadInited) if (!asyncLoadInited)
@ -90,6 +86,8 @@ function RecordDeveloperView({table}: Props): JSX.Element
const tableMetaData = await qController.loadTableMetaData(tableName); const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
recordAnalytics({location: window.location, title: "Developer Mode: " + tableMetaData.label});
////////////////////////////// //////////////////////////////
// load top-level meta-data // // load top-level meta-data //
////////////////////////////// //////////////////////////////
@ -121,7 +119,7 @@ function RecordDeveloperView({table}: Props): JSX.Element
{ {
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "404") if ((e as QException).status === 404)
{ {
setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`); setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`);
return; return;

View File

@ -121,7 +121,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget); const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget);
const closeActionsMenu = () => setActionsMenu(null); const closeActionsMenu = () => setActionsMenu(null);
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive} = useContext(QContext); const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive, recordAnalytics} = useContext(QContext);
if (localStorage.getItem(tableVariantLocalStorageKey)) if (localStorage.getItem(tableVariantLocalStorageKey))
{ {
@ -164,27 +164,27 @@ function RecordView({table, launchProcess}: Props): JSX.Element
if (validType && !dotMenuOpen && !keyboardHelpOpen && !showAudit && !showEditChildForm) if (validType && !dotMenuOpen && !keyboardHelpOpen && !showAudit && !showEditChildForm)
{ {
if (!e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
{ {
e.preventDefault(); e.preventDefault();
gotoCreate(); gotoCreate();
} }
else if (!e.metaKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) else if (!e.metaKey && !e.ctrlKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)
{ {
e.preventDefault(); e.preventDefault();
navigate("edit"); navigate("edit");
} }
else if (!e.metaKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) else if (!e.metaKey && !e.ctrlKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
{ {
e.preventDefault(); e.preventDefault();
navigate("copy"); navigate("copy");
} }
else if (!e.metaKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) else if (!e.metaKey && !e.ctrlKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission)
{ {
e.preventDefault(); e.preventDefault();
handleClickDeleteButton(); handleClickDeleteButton();
} }
else if (!e.metaKey && e.key === "a" && metaData && metaData.tables.has("audit")) else if (!e.metaKey && !e.ctrlKey && e.key === "a" && metaData && metaData.tables.has("audit"))
{ {
e.preventDefault(); e.preventDefault();
navigate("#audit"); navigate("#audit");
@ -384,6 +384,8 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const tableMetaData = await qController.loadTableMetaData(tableName); const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
recordAnalytics({location: window.location, title: "View: " + tableMetaData.label});
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
// load top-level meta-data (e.g., to find processes for table) // // load top-level meta-data (e.g., to find processes for table) //
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
@ -430,6 +432,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{ {
record = await qController.get(tableName, id, tableVariant, null, queryJoins); record = await qController.get(tableName, id, tableVariant, null, queryJoins);
setRecord(record); setRecord(record);
recordAnalytics({category: "tableEvents", action: "view", label: tableMetaData?.label + " / " + record?.recordLabel});
} }
catch (e) catch (e)
{ {
@ -447,13 +450,13 @@ function RecordView({table, launchProcess}: Props): JSX.Element
if (e instanceof QException) if (e instanceof QException)
{ {
if ((e as QException).status === "404") if ((e as QException).status === 404)
{ {
setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`); setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`);
historyPurge(location.pathname); historyPurge(location.pathname);
return; return;
} }
else if ((e as QException).status === "403") else if ((e as QException).status === 403)
{ {
setNotFoundMessage(`You do not have permission to view ${tableMetaData.label} records`); setNotFoundMessage(`You do not have permission to view ${tableMetaData.label} records`);
historyPurge(location.pathname); historyPurge(location.pathname);
@ -505,7 +508,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
sectionFieldElements.set(section.name, sectionFieldElements.set(section.name,
<Grid id={section.name} key={section.name} item lg={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", flexGrow: 1, scrollMarginTop: "100px"}}> <Grid id={section.name} key={section.name} item lg={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", flexGrow: 1, scrollMarginTop: "100px"}}>
<Box width="100%" flexGrow={1} alignItems="stretch"> <Box width="100%" flexGrow={1} alignItems="stretch">
<DashboardWidgets key={section.name} tableName={tableMetaData.name} widgetMetaDataList={[widgetMetaData]} entityPrimaryKey={record.values.get(tableMetaData.primaryKeyField)} omitWrappingGridContainer={true} /> <DashboardWidgets key={section.name} tableName={tableMetaData.name} widgetMetaDataList={[widgetMetaData]} record={record} entityPrimaryKey={record.values.get(tableMetaData.primaryKeyField)} omitWrappingGridContainer={true} />
</Box> </Box>
</Grid> </Grid>
); );
@ -631,6 +634,8 @@ function RecordView({table, launchProcess}: Props): JSX.Element
event?.preventDefault(); event?.preventDefault();
(async () => (async () =>
{ {
recordAnalytics({category: "tableEvents", action: "delete", label: tableMetaData?.label + " / " + record?.recordLabel});
await qController.delete(tableName, id) await qController.delete(tableName, id)
.then(() => .then(() =>
{ {
@ -845,7 +850,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
return ( return (
<BaseLayout> <BaseLayout>
<Box> <Box className="recordView">
<Grid container> <Grid container>
<Grid item xs={12}> <Grid item xs={12}>
<Box mb={3}> <Box mb={3}>

View File

@ -158,6 +158,7 @@ but we've turned off the click-to-sort function, so remove hand cursor */
white-space: normal; white-space: normal;
height: auto; height: auto;
} }
.MuiDataGrid-filterForm .MuiDataGrid-filterForm
{ {
align-items: flex-end; align-items: flex-end;
@ -173,10 +174,12 @@ but we've turned off the click-to-sort function, so remove hand cursor */
{ {
width: 200px; width: 200px;
} }
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput
{ {
width: 300px; width: 300px;
} }
.MuiDataGrid-filterForm .MuiDataGrid-filterFormOperatorInput .MuiDataGrid-filterForm .MuiDataGrid-filterFormOperatorInput
{ {
width: 150px; width: 150px;
@ -187,13 +190,14 @@ but we've turned off the click-to-sort function, so remove hand cursor */
{ {
padding-top: 4px; padding-top: 4px;
} }
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiAutocomplete-root .MuiAutocomplete-endAdornment svg .MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiAutocomplete-root .MuiAutocomplete-endAdornment svg
{ {
height: 0.625em; height: 0.625em;
} }
/* fix strange size bug on filter autocompletes */ /* fix strange size bug on filter autocompletes */
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput>.MuiBox-root>.MuiBox-root:has(>.MuiAutocomplete-root) .MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput > .MuiBox-root > .MuiBox-root:has(>.MuiAutocomplete-root)
{ {
margin-bottom: 0; margin-bottom: 0;
width: 100%; width: 100%;
@ -208,16 +212,31 @@ but we've turned off the click-to-sort function, so remove hand cursor */
} }
/* clears the X from Internet Explorer */ /* clears the X from Internet Explorer */
input[type=search]::-ms-clear { display: none; width : 0; height: 0; } input[type=search]::-ms-clear
input[type=search]::-ms-reveal { display: none; width : 0; height: 0; } {
display: none;
width: 0;
height: 0;
}
input[type=search]::-ms-reveal
{
display: none;
width: 0;
height: 0;
}
/* clears the X from Chrome */ /* clears the X from Chrome */
input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button, input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration { display: none; } input[type="search"]::-webkit-search-results-decoration
{
display: none;
}
/* Shrink the big margin-bottom on modal processes */ /* Shrink the big margin-bottom on modal processes */
.modalProcess>.MuiBox-root>.MuiBox-root .modalProcess > .MuiBox-root > .MuiBox-root
{ {
margin-bottom: 24px; margin-bottom: 24px;
} }
@ -270,6 +289,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
color: initial !important; color: initial !important;
border: 1px solid rgb(206, 212, 218); border: 1px solid rgb(206, 212, 218);
} }
.MuiDataGrid-filterForm .MuiAutocomplete-tag .MuiSvgIcon-root .MuiDataGrid-filterForm .MuiAutocomplete-tag .MuiSvgIcon-root
{ {
color: initial !important; color: initial !important;
@ -287,7 +307,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
right: 0.125rem; right: 0.125rem;
} }
.devDocumentation ul>li .devDocumentation ul > li
{ {
margin-left: 30px; margin-left: 30px;
} }
@ -640,6 +660,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
border: 1px solid #BDBDBD; border: 1px solid #BDBDBD;
border-radius: 0.5rem !important; border-radius: 0.5rem !important;
} }
.MuiToggleButtonGroup-root .MuiButtonBase-root .MuiToggleButtonGroup-root .MuiButtonBase-root
{ {
text-transform: none; text-transform: none;
@ -650,11 +671,25 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
border: none; border: none;
flex: 1 1 0px; flex: 1 1 0px;
} }
.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-selected .MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-selected
{ {
background: rgba(117, 117, 117, 0.20); background: rgba(117, 117, 117, 0.20);
} }
.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-disabled .MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-disabled
{ {
border: none; border: none;
} }
.entityForm h5,
.recordView h5
{
font-weight: 500;
}
.MuiPickersDay-root.Mui-selected, .MuiPickersDay-root.MuiPickersDay-dayWithMargin:hover
{
color: white;
background-color: #0062FF !important;
}

View File

@ -0,0 +1,143 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import Client from "qqq/utils/qqq/Client";
import ReactGA from "react-ga4";
export interface PageView
{
location: Location;
title: string;
}
export interface UserEvent
{
action: string;
category: string;
label?: string;
}
export type AnalyticsModel = PageView | UserEvent;
const qController = Client.getInstance();
/*******************************************************************************
** Utilities for working with Google Analytics (through react-ga4)^
*******************************************************************************/
export default class GoogleAnalyticsUtils
{
private metaData: QInstance = null;
private active: boolean = false;
/*******************************************************************************
**
*******************************************************************************/
constructor()
{
}
/*******************************************************************************
**
*******************************************************************************/
private send = (model: AnalyticsModel) =>
{
if(!this.active)
{
return;
}
if(model.hasOwnProperty("location"))
{
const pageView = model as PageView;
ReactGA.send({hitType: "pageview", page: pageView.location.pathname + pageView.location.search, title: pageView.title});
}
else if(model.hasOwnProperty("action") || model.hasOwnProperty("category") || model.hasOwnProperty("label"))
{
const userEvent = model as UserEvent;
ReactGA.event({action: userEvent.action, category: userEvent.category, label: userEvent.label})
}
else
{
console.log("Unrecognizable analytics model", model);
}
}
/*******************************************************************************
**
*******************************************************************************/
private setup = async (): Promise<void> =>
{
this.metaData = await qController.loadMetaData();
let sessionValues: {[key: string]: any} = null;
try
{
sessionValues = JSON.parse(localStorage.getItem("sessionValues"));
}
catch(e)
{
console.log("Error reading session values from localStorage: " + e);
}
if (this.metaData.environmentValues.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"))
{
this.active = true;
if(sessionValues && sessionValues["googleAnalyticsValues"])
{
ReactGA.gtag("set", "user_properties", sessionValues["googleAnalyticsValues"]);
}
ReactGA.initialize(this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"),
{
gaOptions: {},
gtagOptions: {}
});
}
else
{
this.active = false;
}
}
/*******************************************************************************
**
*******************************************************************************/
public recordAnalytics = (model: AnalyticsModel) =>
{
if(this.metaData == null)
{
(async () =>
{
await this.setup();
})()
}
this.send(model);
}
}

View File

@ -35,7 +35,7 @@ class Client
{ {
console.log(`Caught Exception: ${JSON.stringify(exception)}`); console.log(`Caught Exception: ${JSON.stringify(exception)}`);
if(exception && exception.status == "401" && Client.unauthorizedCallback) if (exception && exception.status == 401 && Client.unauthorizedCallback)
{ {
console.log("This is a 401 - calling the unauthorized callback."); console.log("This is a 401 - calling the unauthorized callback.");
Client.unauthorizedCallback(); Client.unauthorizedCallback();

View File

@ -32,6 +32,7 @@ import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryF
import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {GridSortModel} from "@mui/x-data-grid-pro"; import {GridSortModel} from "@mui/x-data-grid-pro";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -612,6 +613,58 @@ class FilterUtils
} }
} }
/*******************************************************************************
** make a new query filter, based on the input one, but w/ values good for the
** backend. such as, possible values as just ids, not objects w/ a label;
** date-times formatted properly and in UTC
*******************************************************************************/
public static prepQueryFilterForBackend(tableMetaData: QTableMetaData, sourceFilter: QQueryFilter, pageNumber?: number, rowsPerPage?: number): QQueryFilter
{
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.subFilters, sourceFilter.booleanOperator);
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
{
const criteria = sourceFilter.criteria[i];
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
///////////////////////////////////////////////////////////////////////////////////////////
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
///////////////////////////////////////////////////////////////////////////////////////////
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName);
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
}
}
}
/////////////////////////////////////////
// recursively prep subfilters as well //
/////////////////////////////////////////
let subFilters = [] as QQueryFilter[];
for (let j = 0; j < sourceFilter?.subFilters?.length; j++)
{
subFilters.push(FilterUtils.prepQueryFilterForBackend(tableMetaData, sourceFilter.subFilters[j]));
}
filterForBackend.subFilters = subFilters;
if(pageNumber !== undefined && rowsPerPage !== undefined)
{
filterForBackend.skip = pageNumber * rowsPerPage;
filterForBackend.limit = rowsPerPage;
}
return filterForBackend;
};
} }
export default FilterUtils; export default FilterUtils;

View File

@ -219,6 +219,16 @@ class ValueUtils
if (field.type === QFieldType.DATE_TIME) if (field.type === QFieldType.DATE_TIME)
{ {
if(displayValue && displayValue != rawValue)
{
//////////////////////////////////////////////////////////////////////////////
// if the date-time actually has a displayValue set, and it isn't just the //
// raw-value being copied into the display value by whoever called us, then //
// return the display value. //
//////////////////////////////////////////////////////////////////////////////
return displayValue;
}
if (!rawValue) if (!rawValue)
{ {
return (""); return ("");
@ -270,6 +280,7 @@ class ValueUtils
{ {
date = new Date(date); date = new Date(date);
} }
// @ts-ignore // @ts-ignore
return (`${date.toString("yyyy-MM-dd hh:mm:ss")} ${date.getHours() < 12 ? "AM" : "PM"} ${date.getTimezone()}`); return (`${date.toString("yyyy-MM-dd hh:mm:ss")} ${date.getHours() < 12 ? "AM" : "PM"} ${date.getTimezone()}`);
} }