Compare commits

...

97 Commits

Author SHA1 Message Date
a588d3b1c6 Fix path to developer.json fixture 2025-07-18 14:34:18 -05:00
5c2bf9e7b4 Merge pull request #89 from Kingsrook/feature/workflows-support
Feature/workflows support
2025-07-18 12:07:40 -05:00
6a004d6cdb Merge pull request #92 from Kingsrook/feature/sticky-record-buttons
put cancel & save (on insert/edit) and delete & edit (on view) button…
2025-07-18 11:15:05 -05:00
92a71bc62f Make RunFormAdjusterProcess be NOT-PROTECTED permissions 2025-07-14 15:55:16 -05:00
2c65826a91 Fix display value (labels) for PVS fields 2025-07-14 15:54:46 -05:00
86dcc90e1d Support passing possibleValueSourceFilter through to backend, specifically for the "standalone" use-case, where the field doesn't come from a table or process 2025-07-14 15:54:25 -05:00
90fd03ae46 Add omitExposedJoins prop throughout RecordQuery and all subcomponents. Initially for the FilterAndColumnsSetupWidget to allow some joins to not be exposed. 2025-07-14 15:21:58 -05:00
1dce760934 Merge pull request #91 from Kingsrook/feature/criteria-paster-tests
added tests around filter criteria paster tool
2025-07-08 14:34:29 -05:00
ff4683af1f added tests around filter criteria paster tool 2025-07-08 13:53:02 -05:00
ab4be1d5af fix to hotfix, observe chips as well to handle paste 2025-06-30 23:55:42 -05:00
0d7e76df6c hotfix on number chip validity, text fix 2025-06-27 12:17:52 -05:00
d41f5f8339 added clarifying comment 2025-06-20 13:22:29 -05:00
f0f09a8ff1 put cancel & save (on insert/edit) and delete & edit (on view) buttons into a sticky-bottom footer. also change modal edit forms from other edit forms to say OK rather than save. 2025-06-19 14:43:17 -05:00
4d30eb3060 Merge pull request #90 from Kingsrook/feature/search-possible-values-by-label
Feature/search possible values by label
2025-06-18 10:23:13 -05:00
d4a675e952 updated to include the unique count of valid values 2025-06-06 19:17:10 -05:00
633c97b710 fix when no helpContent avaliable 2025-06-03 17:27:11 -05:00
c70ef3dae8 feedback from review session 2025-06-02 16:39:27 -05:00
6f15356b51 Adjustments to qqq/v1 test fixtures 2025-06-02 08:45:30 -05:00
0bf33a01f9 Copy fixture files to qqq/v1 api paths; update routes setup for fixtures too 2025-05-30 20:46:55 -05:00
0bca8e9361 Add new argument to qController.possibleValues call 2025-05-30 20:24:38 -05:00
6b90894425 Merged feature/search-possible-values-by-label into feature/workflows-support 2025-05-30 11:09:15 -05:00
248040a99f Fix dupe call to doRecordAnalytics 2025-05-30 09:20:08 -05:00
80ac2a304a Update qqq-frontend-core version to 1.0.123 (for qControllerV1 count) 2025-05-29 19:18:13 -05:00
b82b25156e Check if javalin classes are available before using (made dep on javalin optional) 2025-05-29 19:16:06 -05:00
69b46570cb Add optional dep for qqq-middleware-javalin; update version of qqq 2025-05-29 19:15:26 -05:00
3da656c01f Remove non-existing fields with a warning (attempt to improve support for api-versioned use-case) 2025-05-29 12:29:35 -05:00
1da0f4f1de Attempting to improve handling for non-countable tables (was showing 1 past the end sometimes);
disable when can't go back or forward;
min-width for more stable UI
2025-05-29 12:28:48 -05:00
ce947bc0f7 Add proxy for /material-dashboard-backend/* (initially for field onLoad/Change form adjusters) 2025-05-29 12:25:39 -05:00
0a42b9d4f0 try to be a little more graceful with fields that don't exist (e.g., other api version use cases) 2025-05-29 12:24:21 -05:00
5ab906bcfe update disabled pagination icons to look disabled 2025-05-29 12:23:49 -05:00
c1ea7081f1 Switch to use QControllerV1 for tableMetaData, query, and count calls, in support of apiVersions; add a pageState of error; setLoading when pageNo or rowsPerPage change; adjust handling of doSetCurrentSavedView, if the saved view record is null 2025-05-29 12:23:11 -05:00
020e174110 Support omitFieldNames to be specified in the widgetData 2025-05-29 11:37:10 -05:00
68c1f897af Add otherValues to form field possibleValues and queryString based on record values in widget load 2025-05-29 11:36:40 -05:00
7d6b083ae2 Try-catch around recordAnalytics calls; reformat file 2025-05-29 11:35:12 -05:00
3d4f0ba24b Update qqq-frontend-core to 1.0.121 2025-05-29 11:33:00 -05:00
6fc11bb0ba Add support for using api-versioned query screen 2025-05-29 11:31:30 -05:00
78c788812a Add support for onLoad and onChange form adjusters, plus isHidden attribute on fields 2025-05-29 11:31:30 -05:00
cb36f59090 Add java backend for field-level form adjusters 2025-05-29 11:31:30 -05:00
96bdcf1874 Add QControllerV1 usage and setGotAuthenticationInAllControllers method to replace calling it on each controller instance 2025-05-29 08:59:09 -05:00
07d116d9ba Adding MaterialDashboardInstanceMetaData with processNamesToAddToAllQueryAndViewScreens - to remove hard-coded version of this which was scripts-menu only - opening up for run-workflows to be added to all tables. 2025-05-28 16:30:15 -05:00
5c69ae666c added ability to search for possible value data using the PVS labels, rather than just the ids, updated the values paster widget thingy to use this change to make pvs requests in a paginated manner 2025-05-27 15:17:57 -05:00
5bdc3a6cd0 Merged dev into feature/workflows-support 2025-05-20 07:53:21 -05:00
2e5aba6c16 Merge tag 'version-0.25.0' into dev
Tag release
2025-05-20 07:52:28 -05:00
185775ca4d Merge branch 'release/0.25.0' 2025-05-20 07:52:28 -05:00
cbcb3b505e Update for next development version 2025-05-20 07:06:37 -05:00
ce91f68088 Update versions for release 2025-05-20 07:06:35 -05:00
81da1a4627 Merged feature/oauth2-authentication-module into dev 2025-05-19 20:33:35 -05:00
bb06e2743a Add initial support for dynamic-components - loaded from a url - as custom widgets. 2025-05-05 11:34:23 -05:00
b279a04b43 quick bug fix for goto fields 2025-04-16 16:45:46 -05:00
1f2e57d688 Merged feature/better-goto-behavior into dev 2025-04-09 11:14:14 -05:00
52bb7ba411 Merged feature/disable-show-default-vs-display-value into dev 2025-04-09 11:14:02 -05:00
34c6f650b5 updated to handle (ignore) fields with empty strings when using goto dialog 2025-04-07 16:50:01 -05:00
d792c23035 Cleanup from code review 2025-04-05 19:58:35 -05:00
e3d30633f1 Refactor authentication handling to pass authentication metadata into App.
eliminates warnings from oauth2 hook by conditionally using its useAuth hook.
2025-04-05 19:37:02 -05:00
a6ee682671 Merged feature/dk-misc-20250318 into dev 2025-04-03 14:28:34 -05:00
c62252075f Merged feature/banners into dev 2025-04-03 14:26:13 -05:00
debc6f3ebf turn off replacing of displayValue with defaultValue 2025-04-02 12:10:39 -05:00
679375ba63 update processClicked to set alert if min/max input records isn't satisfied 2025-03-18 11:44:32 -05:00
fb10dad803 Add support for query-param defaultProcessValues (as a json object) 2025-03-18 11:40:22 -05:00
c9a618c7f6 Fix full-width file upload adornment for lg-size (regressed with field-level grid columns addition) 2025-03-10 12:12:37 -05:00
f654208769 Update kingsrook/qqq-frontend-core to 1.0.118 (add more params to manageSession call) 2025-03-07 20:33:09 -06:00
3dacab8d60 Add support for oauth2 authentication module.
In so doing, extract auth0- and anonymous- -authenticationModule implementations from index.tsx and App.tsx, moving each to it own useXyz hook.
2025-03-07 20:10:06 -06:00
13ce684d23 Initial checkin of Banners under QBrandingMetaData
- includes migration from (now deprecated) MetaDataFilterInterface to MetaDataActionCustomizerInterface (stored on the QInstance and used by MetaDataAction)
- includes migration from (now deprecated) environmentBannerText and environmentBannerColor in QBrandingMetaData to now be implemented as a banner
2025-03-07 14:58:51 -06:00
b67eea7d87 Merge branch 'release/0.24.0' 2025-03-06 11:20:23 -06:00
8ae3b95105 Merge tag 'version-0.24.0' into dev
Tag release
2025-03-06 11:20:23 -06:00
5a309e5628 Update for next development version 2025-03-06 11:04:18 -06:00
67e1e78817 Update versions for release 2025-03-06 11:04:17 -06:00
214b6b8af4 Merged feature/sftp-and-headless-bulk-load into dev 2025-03-05 19:45:22 -06:00
8ec0ce5455 Cleanup from code review 2025-03-05 19:30:52 -06:00
07cb6fd323 Fix show blob download urls when not using dynamic url 2025-02-25 10:55:49 -06:00
3bb8451671 add support for toRecordFromTableDynamic in LINK adornment, and downloadUrlDynamic in FILE_DOWNLOAD adornment 2025-02-24 11:10:36 -06:00
6076c4ddfd CE-2261: updated to respect field column widths on view and edit forms 2025-02-19 17:05:10 -06:00
44a8810260 Remove textTransform="capitalize" from pageHeader h3 2025-02-14 20:37:12 -06:00
c69a4b8203 Make variants work for blob/download fields 2025-02-14 20:36:49 -06:00
7db4f34ddd add LONG to types that get numeric operators 2025-02-14 20:34:59 -06:00
71dc3f3f65 Merge pull request #79 from Kingsrook/feature/support-CE-2257-ice-logic
Add support for defaultValuesForNewChildRecordsFromParentFields for C…
2025-02-14 20:33:47 -06:00
ce22db2f89 Merge pull request #78 from Kingsrook/feature/CE-2258-manual-add-carton [skip ci]
CE-2258: updated dashboard widgets with a forcereload when child reco…
2025-02-14 20:32:15 -06:00
aacb239164 Add support for defaultValuesForNewChildRecordsFromParentFields for ChildRecordList; Load display values for possible-value fields when adding them to childRecord list and when opening child-edit form (adding passing of all other-values to the possible-value lookup, for filtered scenarios that need them); 2025-02-03 09:10:39 -06:00
219458ec63 CE-2258: updated dashboard widgets with a forcereload when child record is removed 2025-01-28 15:30:42 -06:00
59fdc72455 Merged feature/filter-json-field-improvements into dev 2025-01-22 20:01:10 -06:00
5c3ddb7dec Take label (e.g., of the field) as parameter 2025-01-21 12:18:21 -06:00
d65c1fb5d8 Padding & margin adjustments for script viewer 2025-01-21 12:12:03 -06:00
19a63d6956 Read filterFieldName and columnsFieldName from widgetData 2025-01-14 10:56:07 -06:00
40f5b55307 CE-1955 add error if no fields mapped 2025-01-07 11:47:52 -06:00
7320b19fbb CE-1955 Add warning about duplicate column headers, and un-selection of dupes if switching from no-header-row mode to header-row mode 2025-01-07 10:12:45 -06:00
3f8a3e7e4d CE-1955 Fix (new) switchLayout method to ... actually save the new layout 2025-01-06 16:52:19 -06:00
3ef2d64327 CE-1955 Bulk load bugs & usability improvements 2024-12-27 14:58:40 -06:00
d793c23861 CE-1955 Add guard around a call to onChangeCallback 2024-12-26 19:14:21 -06:00
d0201d96e1 CE-1955 Fix select box handling of 'x' and typing... 2024-12-26 19:13:48 -06:00
7b66ece466 Try to avoid an error a user is getting where no operatorSeletedValue is being selected when page is loading 2024-12-10 09:14:32 -06:00
02c163899a CE-1955 Handle associated fields; better messaging w/ undefined values 2024-12-04 16:11:49 -06:00
8fafe16a95 CE-1955 handle currentSavedBulkLoadProfile being set, when going back to this screen 2024-12-04 16:11:08 -06:00
722c8d3bcf CE-1955 Update to fetch label for possible-values being used as a default value 2024-12-04 16:10:33 -06:00
85acb612c9 CE-1955 Add add ? to record.associatedRecords?.get to avoid crash if no associations 2024-12-03 20:47:50 -06:00
74c634414a CE-1955 Add helpContent to hasHeaderRow and layout fields 2024-12-03 20:47:27 -06:00
f8368b030c CE-1955 make PreviewRecordUsingTableLayout a private component - try to make it re-render the associated child grids when switching records 2024-12-03 15:55:18 -06:00
dda4ea4f4b CE-1955 Delete an unused effect 2024-12-03 15:53:23 -06:00
98 changed files with 10620 additions and 20490 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
.yalc*
yalc.lock
.env
/certs
# dependencies
/node_modules

23618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.113",
"@kingsrook/qqq-frontend-core": "1.0.124",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -36,6 +36,8 @@
"html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6",
"jwt-decode": "3.1.2",
"lodash": "4.17.21",
"oidc-client-ts": "2.4.1",
"rapidoc": "9.3.4",
"react": "18.0.0",
"react-ace": "10.1.0",
@ -49,6 +51,7 @@
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
"react-oidc-context": "2.3.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",

10
pom.xml
View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.24.0-SNAPSHOT</revision>
<revision>0.26.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,13 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.21.0</version>
<version>0.26.0-integration-20250529-234230</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<optional>true</optional>
<version>0.26.0-integration-20250529-234230</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>

View File

@ -19,7 +19,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {useAuth0} from "@auth0/auth0-react";
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
@ -29,16 +28,20 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import CommandMenu from "CommandMenu";
import jwt_decode from "jwt-decode";
import QContext from "QContext";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme";
import {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
import AppHome from "qqq/pages/apps/Home";
import NoApps from "qqq/pages/apps/NoApps";
import ProcessRun from "qqq/pages/processes/ProcessRun";
@ -62,10 +65,14 @@ import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App()
interface Props
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0();
authenticationMetaData: QAuthenticationMetaData;
}
export default function App({authenticationMetaData}: Props)
{
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
const [profileRoutes, setProfileRoutes] = useState({});
@ -74,68 +81,20 @@ export default function App()
const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element);
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
/////////////////////////////////////////////////////////
Client.setUnauthorizedCallback(() =>
{
logout();
});
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
Client.setUnauthorizedCallback(() => doLogout());
/////////////////////////////////////////////////
// deal with making sure user is authenticated //
/////////////////////////////////////////////////
useEffect(() =>
{
if (loadingToken)
@ -146,65 +105,17 @@ export default function App()
(async () =>
{
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
if (authenticationMetaData.type === "AUTH_0")
{
/////////////////////////////////////////
// use auth0 if auth type is ... auth0 //
/////////////////////////////////////////
try
{
console.log("Loading token from auth0...");
const accessToken = await getAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
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. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(user);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout();
return;
}
await auth0SetupSession();
}
else if (authenticationMetaData.type === "OAUTH2")
{
await oauth2SetupSession();
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
/////////////////////////////////////////////
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
await anonymousSetupSession();
}
else
{
@ -220,13 +131,36 @@ export default function App()
(async () =>
{
const metaData: QInstance = await qController.loadMetaData();
LicenseInfo.setLicenseKey(metaData.environmentValues.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
LicenseInfo.setLicenseKey(metaData.environmentValues?.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
setNeedLicenseKey(false);
})();
}
/***************************************************************************
** call appropriate logout function based on authentication meta data type
***************************************************************************/
function doLogout()
{
if (authenticationMetaData?.type === "AUTH_0")
{
auth0Logout();
}
else if (authenticationMetaData?.type === "OAUTH2")
{
oauth2Logout();
}
else if (authenticationMetaData?.type === "FULLY_ANONYMOUS" || authenticationMetaData?.type === "MOCK")
{
anonymousLogout();
}
else
{
console.log(`No logout callback for authentication type [${authenticationMetaData?.type}].`);
}
}
const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const {miniSidenav, direction, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
@ -443,23 +377,57 @@ export default function App()
});
});
const runRecordScriptProcess = metaData.processes.get("runRecordScript");
if (runRecordScriptProcess)
const materialDashboardInstanceMetaData = metaData.supplementalInstanceMetaData?.get("materialDashboard");
if (materialDashboardInstanceMetaData)
{
const process = runRecordScriptProcess;
routeList.push({
name: process.label,
key: process.name,
route: `${path}/${process.name}`,
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
});
const processNamesToAddToAllQueryAndViewScreens = materialDashboardInstanceMetaData.processNamesToAddToAllQueryAndViewScreens;
if (processNamesToAddToAllQueryAndViewScreens)
{
for (let processName of processNamesToAddToAllQueryAndViewScreens)
{
const process = metaData.processes.get(processName);
if (process)
{
routeList.push({
name: process.label,
key: process.name,
route: `${path}/${process.name}`,
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
});
routeList.push({
name: process.label,
key: `${app.name}/${process.name}`,
route: `${path}/:id/${process.name}`,
component: <RecordView table={table} launchProcess={process} />,
});
routeList.push({
name: process.label,
key: `${app.name}/${process.name}`,
route: `${path}/:id/${process.name}`,
component: <RecordView table={table} launchProcess={process} />,
});
}
}
}
}
else
{
////////////////
// deprecated //
////////////////
const runRecordScriptProcess = metaData.processes.get("runRecordScript");
if (runRecordScriptProcess)
{
const process = runRecordScriptProcess;
routeList.push({
name: process.label,
key: process.name,
route: `${path}/${process.name}`,
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
});
routeList.push({
name: process.label,
key: `${app.name}/${process.name}`,
route: `${path}/:id/${process.name}`,
component: <RecordView table={table} launchProcess={process} />,
});
}
}
const reportsForTable = ProcessUtils.getReportsForTable(metaData, table.name, true);
@ -519,11 +487,10 @@ export default function App()
}
}
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = {
const profileRoutes = {
type: "collapse",
name: loggedInUser?.name ?? "Anonymous",
key: "username",
@ -592,10 +559,7 @@ export default function App()
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
//////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic //
//////////////////////////////////////////////////////
logout();
doLogout();
return;
}
}
@ -603,7 +567,9 @@ export default function App()
})();
}, [needToLoadRoutes, isFullyAuthenticated]);
// Open sidenav when mouse enter on mini sidenav
///////////////////////////////////////////////////
// Open sidenav when mouse enter on mini sidenav //
///////////////////////////////////////////////////
const handleOnMouseEnter = () =>
{
if (miniSidenav && !onMouseEnter)
@ -613,7 +579,9 @@ export default function App()
}
};
// Close sidenav when mouse leave mini sidenav
/////////////////////////////////////////////////
// Close sidenav when mouse leave mini sidenav //
/////////////////////////////////////////////////
const handleOnMouseLeave = () =>
{
if (onMouseEnter)
@ -623,16 +591,14 @@ export default function App()
}
};
// Change the openConfigurator state
const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator);
// Setting the dir attribute for the body element
useEffect(() =>
{
document.body.setAttribute("dir", direction);
}, [direction]);
// Setting page scroll to 0 when changing the route
//////////////////////////////////////////////////////
// Setting page scroll to 0 when changing the route //
//////////////////////////////////////////////////////
useEffect(() =>
{
document.documentElement.scrollTop = 0;
@ -672,14 +638,14 @@ export default function App()
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [userId, setUserId] = useState(user?.email);
const [userId, setUserId] = useState(loggedInUser?.email);
useEffect(() =>
{
setUserId(user?.email)
}, [user]);
setUserId(loggedInUser?.email);
}, [loggedInUser]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************
@ -687,9 +653,35 @@ export default function App()
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model)
googleAnalyticsUtils.recordAnalytics(model);
}
///////////////////////////////////////////////////////////////////
// if any of the auth/session setup code determined that we need //
// to render something and return early - then do so here. //
///////////////////////////////////////////////////////////////////
if (earlyReturnForAuth)
{
return (earlyReturnForAuth);
}
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_SITE");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return (
@ -718,6 +710,7 @@ export default function App()
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData} />
{banner()}
<Sidenav
color={sidenavColor}
icon={branding.icon}
@ -727,6 +720,7 @@ export default function App()
routes={sideNavRoutes}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
logout={doLogout}
/>
<Routes>
<Route path="*" element={<Navigate to={defaultRoute} />} />

View File

@ -19,116 +19,104 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Auth0Provider} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import React from "react";
import ReactDOM from "react-dom";
import {createRoot} from "react-dom/client";
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
import App from "App";
import "qqq/styles/qqq-override-styles.css";
import "qqq/styles/globals.scss";
import "qqq/styles/raycast.scss";
import HandleAuthorizationError from "HandleAuthorizationError";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
/////////////////////////////////////////////////////////////////////////////////
// Expose React and ReactDOM as globals, for use by dynamically loaded modules //
/////////////////////////////////////////////////////////////////////////////////
(window as any).React = React;
(window as any).ReactDOM = ReactDOM;
const qController = Client.getInstance();
if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
{
qController.clearAuthenticationMetaDataLocalStorage()
qController.clearAuthenticationMetaDataLocalStorage();
}
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData()
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData();
authenticationMetaDataPromise.then((authenticationMetaData) =>
{
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
/***************************************************************************
**
***************************************************************************/
function Auth0RouterBody()
{
const {renderAppWrapper} = useAuth0AuthenticationModule({});
return (renderAppWrapper(authenticationMetaData));
}
/***************************************************************************
**
***************************************************************************/
function OAuth2RouterBody()
{
const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
/***************************************************************************
**
***************************************************************************/
function AnonymousRouterBody()
{
const {renderAppWrapper} = useAnonymousAuthenticationModule({});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
const container = document.getElementById("root");
const root = createRoot(container);
if (authenticationMetaData.type === "AUTH_0")
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if(!domain || !clientId)
{
root.render(
<div>Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].</div>
);
return;
}
if(domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
root.render(
<BrowserRouter>
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}
>
<MaterialUIControllerProvider>
<ProtectedRoute component={App} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
</BrowserRouter>
);
root.render(<BrowserRouter>
<Auth0RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "OAUTH2")
{
root.render(<BrowserRouter>
<OAuth2RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
root.render(<BrowserRouter>
<AnonymousRouterBody />
</BrowserRouter>);
}
else
{
root.render(
<BrowserRouter>
<MaterialUIControllerProvider>
<App />
</MaterialUIControllerProvider>
</BrowserRouter>
);
root.render(<div>
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
</div>);
}
})
});

View File

@ -0,0 +1,164 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.actions.formadjuster;
import java.io.Serializable;
import java.util.Map;
/*******************************************************************************
**
*******************************************************************************/
public class FormAdjusterInput
{
private String event;
private String fieldName;
private Serializable newValue;
private Map<String, Serializable> allValues;
/*******************************************************************************
** Getter for event
*******************************************************************************/
public String getEvent()
{
return (this.event);
}
/*******************************************************************************
** Setter for event
*******************************************************************************/
public void setEvent(String event)
{
this.event = event;
}
/*******************************************************************************
** Fluent setter for event
*******************************************************************************/
public FormAdjusterInput withEvent(String event)
{
this.event = event;
return (this);
}
/*******************************************************************************
** Getter for fieldName
*******************************************************************************/
public String getFieldName()
{
return (this.fieldName);
}
/*******************************************************************************
** Setter for fieldName
*******************************************************************************/
public void setFieldName(String fieldName)
{
this.fieldName = fieldName;
}
/*******************************************************************************
** Fluent setter for fieldName
*******************************************************************************/
public FormAdjusterInput withFieldName(String fieldName)
{
this.fieldName = fieldName;
return (this);
}
/*******************************************************************************
** Getter for newValue
*******************************************************************************/
public Serializable getNewValue()
{
return (this.newValue);
}
/*******************************************************************************
** Setter for newValue
*******************************************************************************/
public void setNewValue(Serializable newValue)
{
this.newValue = newValue;
}
/*******************************************************************************
** Fluent setter for newValue
*******************************************************************************/
public FormAdjusterInput withNewValue(Serializable newValue)
{
this.newValue = newValue;
return (this);
}
/*******************************************************************************
** Getter for allValues
*******************************************************************************/
public Map<String, Serializable> getAllValues()
{
return (this.allValues);
}
/*******************************************************************************
** Setter for allValues
*******************************************************************************/
public void setAllValues(Map<String, Serializable> allValues)
{
this.allValues = allValues;
}
/*******************************************************************************
** Fluent setter for allValues
*******************************************************************************/
public FormAdjusterInput withAllValues(Map<String, Serializable> allValues)
{
this.allValues = allValues;
return (this);
}
}

View File

@ -0,0 +1,39 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.actions.formadjuster;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** interface to be implemented by application-specific form-adjusters
*******************************************************************************/
public interface FormAdjusterInterface
{
/***************************************************************************
*
***************************************************************************/
FormAdjusterOutput execute(FormAdjusterInput input) throws QException;
}

View File

@ -0,0 +1,165 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.actions.formadjuster;
import java.io.Serializable;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData;
/*******************************************************************************
**
*******************************************************************************/
public class FormAdjusterOutput
{
private Map<String, QFrontendFieldMetaData> updatedFieldMetaData = null;
private Map<String, Serializable> updatedFieldValues = null;
private Map<String, String> updatedFieldDisplayValues = null;
private Set<String> fieldsToClear = null;
/*******************************************************************************
** Getter for updatedFieldValues
*******************************************************************************/
public Map<String, Serializable> getUpdatedFieldValues()
{
return (this.updatedFieldValues);
}
/*******************************************************************************
** Setter for updatedFieldValues
*******************************************************************************/
public void setUpdatedFieldValues(Map<String, Serializable> updatedFieldValues)
{
this.updatedFieldValues = updatedFieldValues;
}
/*******************************************************************************
** Fluent setter for updatedFieldValues
*******************************************************************************/
public FormAdjusterOutput withUpdatedFieldValues(Map<String, Serializable> updatedFieldValues)
{
this.updatedFieldValues = updatedFieldValues;
return (this);
}
/*******************************************************************************
** Getter for fieldsToClear
*******************************************************************************/
public Set<String> getFieldsToClear()
{
return (this.fieldsToClear);
}
/*******************************************************************************
** Setter for fieldsToClear
*******************************************************************************/
public void setFieldsToClear(Set<String> fieldsToClear)
{
this.fieldsToClear = fieldsToClear;
}
/*******************************************************************************
** Fluent setter for fieldsToClear
*******************************************************************************/
public FormAdjusterOutput withFieldsToClear(Set<String> fieldsToClear)
{
this.fieldsToClear = fieldsToClear;
return (this);
}
/*******************************************************************************
** Getter for updatedFieldMetaData
*******************************************************************************/
public Map<String, QFrontendFieldMetaData> getUpdatedFieldMetaData()
{
return (this.updatedFieldMetaData);
}
/*******************************************************************************
** Setter for updatedFieldMetaData
*******************************************************************************/
public void setUpdatedFieldMetaData(Map<String, QFrontendFieldMetaData> updatedFieldMetaData)
{
this.updatedFieldMetaData = updatedFieldMetaData;
}
/*******************************************************************************
** Fluent setter for updatedFieldMetaData
*******************************************************************************/
public FormAdjusterOutput withUpdatedFieldMetaData(Map<String, QFrontendFieldMetaData> updatedFieldMetaData)
{
this.updatedFieldMetaData = updatedFieldMetaData;
return (this);
}
/*******************************************************************************
** Getter for updatedFieldDisplayValues
*******************************************************************************/
public Map<String, String> getUpdatedFieldDisplayValues()
{
return (this.updatedFieldDisplayValues);
}
/*******************************************************************************
** Setter for updatedFieldDisplayValues
*******************************************************************************/
public void setUpdatedFieldDisplayValues(Map<String, String> updatedFieldDisplayValues)
{
this.updatedFieldDisplayValues = updatedFieldDisplayValues;
}
/*******************************************************************************
** Fluent setter for updatedFieldDisplayValues
*******************************************************************************/
public FormAdjusterOutput withUpdatedFieldDisplayValues(Map<String, String> updatedFieldDisplayValues)
{
this.updatedFieldDisplayValues = updatedFieldDisplayValues;
return (this);
}
}

View File

@ -0,0 +1,149 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.actions.formadjuster;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.ClassPathUtils;
import com.kingsrook.qqq.backend.javalin.QJavalinMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardFieldMetaData;
import com.kingsrook.qqq.middleware.javalin.metadata.JavalinRouteProviderMetaData;
/*******************************************************************************
** Class that stores code-references for the application's defined fromAdjusters
** This class also, when registering its first formAdjuster, adds the route to
** the javalin instance to service form-adjuster calls from the frontend.
*******************************************************************************/
public class FormAdjusterRegistry
{
private static final QLogger LOG = QLogger.getLogger(FormAdjusterRegistry.class);
private static boolean didRegisterRouteProvider = false;
private static QInstance lastRegisteredQInstance = null;
private static Map<String, QCodeReference> onChangeAdjusters = new HashMap<>();
private static Map<String, QCodeReference> onLoadAdjusters = new HashMap<>();
/***************************************************************************
**
***************************************************************************/
public static void registerFormAdjusters(QInstance qInstance, MaterialDashboardFieldMetaData materialDashboardFieldMetaData) throws QException
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// support hot-swaps, by checking if the input qInstance is different from one we previously registered for //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(didRegisterRouteProvider && lastRegisteredQInstance != qInstance)
{
didRegisterRouteProvider = false;
onChangeAdjusters.clear();
onLoadAdjusters.clear();
}
/////////////////////////////////////////////////////////////////////////////////////
// if we need to register the javalin router, do so (only once per qInstance) //
// note, javalin is optional dep, so make sure it's available before try to use it //
/////////////////////////////////////////////////////////////////////////////////////
if(!didRegisterRouteProvider)
{
if(ClassPathUtils.isClassAvailable(QJavalinMetaData.class.getName()))
{
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
javalinMetaData.withRouteProvider(new JavalinRouteProviderMetaData()
.withHostedPath("/material-dashboard-backend/form-adjuster/{identifier}/{event}")
.withMethods(List.of("POST"))
.withProcessName(RunFormAdjusterProcess.NAME)
);
qInstance.add(new RunFormAdjusterProcess().produce(qInstance));
}
didRegisterRouteProvider = true;
lastRegisteredQInstance = qInstance;
}
////////////////////////////////////////////////////////////////
// add the code-references to the map of registered adjusters //
////////////////////////////////////////////////////////////////
String identifier = materialDashboardFieldMetaData.getFormAdjusterIdentifier();
QCodeReference onChangeCode = materialDashboardFieldMetaData.getOnChangeFormAdjuster();
if(onChangeCode != null)
{
if(onChangeAdjusters.containsKey(identifier))
{
LOG.warn("Attempt to register more than one onChangeFormAdjuster with identifier: " + identifier);
}
onChangeAdjusters.put(identifier, onChangeCode);
}
QCodeReference onLoadCode = materialDashboardFieldMetaData.getOnLoadFormAdjuster();
if(onLoadCode != null)
{
if(onLoadAdjusters.containsKey(identifier))
{
LOG.warn("Attempt to register more than one onLoadFormAdjuster with identifier: " + identifier);
}
onLoadAdjusters.put(identifier, onLoadCode);
}
}
/***************************************************************************
**
***************************************************************************/
static FormAdjusterInterface getOnChangeAdjuster(String identifier)
{
QCodeReference codeReference = onChangeAdjusters.get(identifier);
if(codeReference != null)
{
return QCodeLoader.getAdHoc(FormAdjusterInterface.class, codeReference);
}
return (null);
}
/***************************************************************************
**
***************************************************************************/
static FormAdjusterInterface getOnLoadAdjuster(String identifier)
{
QCodeReference codeReference = onLoadAdjusters.get(identifier);
if(codeReference != null)
{
return QCodeLoader.getAdHoc(FormAdjusterInterface.class, codeReference);
}
return (null);
}
}

View File

@ -0,0 +1,123 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.actions.formadjuster;
import java.io.Serializable;
import java.util.Collections;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.routeproviders.ProcessBasedRouterPayload;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** process that looks up a form adjuster from the registry, and then runs it
*******************************************************************************/
public class RunFormAdjusterProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "MaterialDashboardRunFormAdjusterProcess";
private static final QLogger LOG = QLogger.getLogger(RunFormAdjusterProcess.class);
public static final String EVENT_ON_LOAD = "onLoad";
public static final String EVENT_ON_CHANGE = "onChange";
/***************************************************************************
**
***************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withStep(new QBackendStepMetaData()
.withName("execute")
.withCode(new QCodeReference(getClass())));
}
/***************************************************************************
**
***************************************************************************/
@Override
public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
{
ProcessBasedRouterPayload payload = runBackendStepInput.getProcessPayload(ProcessBasedRouterPayload.class);
String identifier = payload.getPathParams().get("identifier");
String event = payload.getPathParams().get("event");
try
{
FormAdjusterInterface formAdjuster = switch(event)
{
case EVENT_ON_CHANGE -> FormAdjusterRegistry.getOnChangeAdjuster(identifier);
case EVENT_ON_LOAD -> FormAdjusterRegistry.getOnLoadAdjuster(identifier);
default -> throw new QException("Unknown event type: " + event);
};
if(formAdjuster == null)
{
throw new QException("No form adjuster found for identifier: " + identifier + " and event: " + event);
}
FormAdjusterInput input = new FormAdjusterInput();
input.setEvent(event);
input.setFieldName(payload.getFormParam("fieldName"));
input.setNewValue(payload.getFormParam("newValue"));
String allValuesJson = payload.getFormParam("allValues");
Map<String, Serializable> allValues = StringUtils.hasContent(allValuesJson) ? JsonUtils.toObject(allValuesJson, new TypeReference<>() {}) : Collections.emptyMap();
input.setAllValues(allValues);
FormAdjusterOutput output = formAdjuster.execute(input);
payload.setResponseString(JsonUtils.toJson(output));
runBackendStepOutput.setProcessPayload(payload);
}
catch(Exception e)
{
LOG.warn("Error running form adjuster process", e, logPair("identifier", identifier), logPair("event", event));
throw new QException("Error running form adjuster process: " + e.getMessage(), e);
}
}
}

View File

@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
@ -19,20 +19,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {useAuth0} from "@auth0/auth0-react";
import {Button} from "@mui/material";
import React from "react";
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
function AuthenticationButton()
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
/*******************************************************************************
**
*******************************************************************************/
public enum MaterialDashboardBannerSlots implements BannerSlot
{
const {loginWithRedirect, logout, isAuthenticated} = useAuth0();
if (isAuthenticated)
{
return <Button onClick={() => logout({returnTo: window.location.origin})}>Log Out</Button>;
}
return <Button onClick={() => loginWithRedirect()}>Log In</Button>;
QFMD_TOP_OF_SITE,
QFMD_TOP_OF_BODY,
QFMD_SIDE_NAV_UNDER_LOGO
}
export default AuthenticationButton;

View File

@ -0,0 +1,244 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.Set;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterInterface;
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterRegistry;
/*******************************************************************************
**
*******************************************************************************/
public class MaterialDashboardFieldMetaData extends QSupplementalFieldMetaData
{
public static final String TYPE = "materialDashboard";
private static final QLogger LOG = QLogger.getLogger(MaterialDashboardFieldMetaData.class);
private String formAdjusterIdentifier = null;
private QCodeReference onChangeFormAdjuster = null;
private QCodeReference onLoadFormAdjuster = null;
private Set<String> fieldsToDisableWhileRunningAdjusters = null;
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean includeInFrontendMetaData()
{
return (true);
}
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return TYPE;
}
/*******************************************************************************
** Getter for onChangeFormAdjuster
*******************************************************************************/
public QCodeReference getOnChangeFormAdjuster()
{
return (this.onChangeFormAdjuster);
}
/*******************************************************************************
** Setter for onChangeFormAdjuster
*******************************************************************************/
public void setOnChangeFormAdjuster(QCodeReference onChangeFormAdjuster)
{
this.onChangeFormAdjuster = onChangeFormAdjuster;
}
/*******************************************************************************
** Fluent setter for onChangeFormAdjuster
*******************************************************************************/
public MaterialDashboardFieldMetaData withOnChangeFormAdjuster(QCodeReference onChangeFormAdjuster)
{
this.onChangeFormAdjuster = onChangeFormAdjuster;
return (this);
}
/*******************************************************************************
** Getter for onLoadFormAdjuster
*******************************************************************************/
public QCodeReference getOnLoadFormAdjuster()
{
return (this.onLoadFormAdjuster);
}
/*******************************************************************************
** Setter for onLoadFormAdjuster
*******************************************************************************/
public void setOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
{
this.onLoadFormAdjuster = onLoadFormAdjuster;
}
/*******************************************************************************
** Fluent setter for onLoadFormAdjuster
*******************************************************************************/
public MaterialDashboardFieldMetaData withOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
{
this.onLoadFormAdjuster = onLoadFormAdjuster;
return (this);
}
/***************************************************************************
**
***************************************************************************/
@Override
public void enrich(QInstance qInstance, QFieldMetaData fieldMetaData)
{
try
{
FormAdjusterRegistry.registerFormAdjusters(qInstance, this);
}
catch(Exception e)
{
LOG.warn("Error enriching MaterialDashboardFieldMetaData", e);
}
}
/***************************************************************************
**
***************************************************************************/
@Override
public void validate(QInstance qInstance, QFieldMetaData fieldMetaData, QInstanceValidator qInstanceValidator)
{
String prefix = "MaterialDashboardFieldMetaData for field [" + fieldMetaData.getName() + "]";
boolean needsFormAdjusterIdentifer = false;
if(onChangeFormAdjuster != null)
{
needsFormAdjusterIdentifer = true;
qInstanceValidator.validateSimpleCodeReference(prefix + ", onChangeFormAdjuster", onChangeFormAdjuster, FormAdjusterInterface.class);
}
if(onLoadFormAdjuster != null)
{
needsFormAdjusterIdentifer = true;
qInstanceValidator.validateSimpleCodeReference(prefix + ", onLoadFormAdjuster", onLoadFormAdjuster, FormAdjusterInterface.class);
}
if(needsFormAdjusterIdentifer)
{
qInstanceValidator.assertCondition(StringUtils.hasContent(formAdjusterIdentifier), prefix + ", formAdjusterIdentifier is required if using any FormAdjusters");
}
}
/*******************************************************************************
** Getter for formAdjusterIdentifier
*******************************************************************************/
public String getFormAdjusterIdentifier()
{
return (this.formAdjusterIdentifier);
}
/*******************************************************************************
** Setter for formAdjusterIdentifier
*******************************************************************************/
public void setFormAdjusterIdentifier(String formAdjusterIdentifier)
{
this.formAdjusterIdentifier = formAdjusterIdentifier;
}
/*******************************************************************************
** Fluent setter for formAdjusterIdentifier
*******************************************************************************/
public MaterialDashboardFieldMetaData withFormAdjusterIdentifier(String formAdjusterIdentifier)
{
this.formAdjusterIdentifier = formAdjusterIdentifier;
return (this);
}
/*******************************************************************************
** Getter for fieldsToDisableWhileRunningAdjusters
*******************************************************************************/
public Set<String> getFieldsToDisableWhileRunningAdjusters()
{
return (this.fieldsToDisableWhileRunningAdjusters);
}
/*******************************************************************************
** Setter for fieldsToDisableWhileRunningAdjusters
*******************************************************************************/
public void setFieldsToDisableWhileRunningAdjusters(Set<String> fieldsToDisableWhileRunningAdjusters)
{
this.fieldsToDisableWhileRunningAdjusters = fieldsToDisableWhileRunningAdjusters;
}
/*******************************************************************************
** Fluent setter for fieldsToDisableWhileRunningAdjusters
*******************************************************************************/
public MaterialDashboardFieldMetaData withFieldsToDisableWhileRunningAdjusters(Set<String> fieldsToDisableWhileRunningAdjusters)
{
this.fieldsToDisableWhileRunningAdjusters = fieldsToDisableWhileRunningAdjusters;
return (this);
}
}

View File

@ -0,0 +1,113 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
/*******************************************************************************
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
*******************************************************************************/
public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceMetaData
{
public static final String TYPE = "materialDashboard";
private List<String> processNamesToAddToAllQueryAndViewScreens = new ArrayList<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (TYPE);
}
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardInstanceMetaData ofOrWithNew(QInstance qInstance)
{
MaterialDashboardInstanceMetaData supplementalMetaData = (MaterialDashboardInstanceMetaData) qInstance.getSupplementalMetaData(TYPE);
if(supplementalMetaData == null)
{
supplementalMetaData = new MaterialDashboardInstanceMetaData();
qInstance.withSupplementalMetaData(supplementalMetaData);
}
return (supplementalMetaData);
}
/*******************************************************************************
** Getter for processNamesToAddToAllQueryAndViewScreens
*******************************************************************************/
public List<String> getProcessNamesToAddToAllQueryAndViewScreens()
{
return (this.processNamesToAddToAllQueryAndViewScreens);
}
/*******************************************************************************
**
*******************************************************************************/
public void addProcessNameToAddToAllQueryAndViewScreens(String processNamesToAddToAllQueryAndViewScreens)
{
if(this.processNamesToAddToAllQueryAndViewScreens == null)
{
this.processNamesToAddToAllQueryAndViewScreens = new ArrayList<>();
}
this.processNamesToAddToAllQueryAndViewScreens.add(processNamesToAddToAllQueryAndViewScreens);
}
/*******************************************************************************
** Setter for processNamesToAddToAllQueryAndViewScreens
*******************************************************************************/
public void setProcessNamesToAddToAllQueryAndViewScreens(List<String> processNamesToAddToAllQueryAndViewScreens)
{
this.processNamesToAddToAllQueryAndViewScreens = processNamesToAddToAllQueryAndViewScreens;
}
/*******************************************************************************
** Fluent setter for processNamesToAddToAllQueryAndViewScreens
*******************************************************************************/
public MaterialDashboardInstanceMetaData withProcessNamesToAddToAllQueryAndViewScreens(List<String> processNamesToAddToAllQueryAndViewScreens)
{
this.processNamesToAddToAllQueryAndViewScreens = processNamesToAddToAllQueryAndViewScreens;
return (this);
}
}

View File

@ -0,0 +1,82 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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 {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the anonymous authentication module
***************************************************************************/
export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
console.log("Generating random token...");
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
return children;
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -0,0 +1,253 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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 {Auth0Provider, useAuth0} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import App, {SESSION_UUID_COOKIE_NAME} from "App";
import HandleAuthorizationError from "HandleAuthorizationError";
import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
const qControllerV1 = Client.getInstanceV1();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the Auth0 authentication module
***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
console.log("Loading token from auth0...");
const accessToken = await auth0GetAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setLoggedInUser(auth0User);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
useAuth0Logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
useAuth0Logout({returnTo: window.location.origin});
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
}
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if (!domain || !clientId)
{
return (
<div>Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].</div>
);
}
if (domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
/***************************************************************************
** simple Functional Component to wrap the <App> and pass the authentication-
** MetaData prop in, so a simple Component can be passed into ProtectedRoute
***************************************************************************/
function WrappedApp()
{
return <App authenticationMetaData={authenticationMetaData} />
}
return (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -0,0 +1,188 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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 {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
inOAuthContext: boolean;
}
/***************************************************************************
** hook for working with the OAuth2 authentication module
***************************************************************************/
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{
///////////////////////////////////////////////////////////////////////////////////////
// the useAuth hook should only be called if we're inside the <AuthProvider> element //
// so on the page that uses this hook to call renderAppWrapper, we aren't in that //
// element/context, thus, don't call that hook. //
///////////////////////////////////////////////////////////////////////////////////////
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token")
{
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
// from that state in local storage, and make a post to the backend to //
// with these values - which will itself talk to the identity provider //
// to get an access token, and ultimately a session. //
///////////////////////////////////////////////////////////////////////////
const code = searchParams.get("code");
const state = searchParams.get("state");
const oidcString = localStorage.getItem(`oidc.${state}`);
if (oidcString)
{
const oidcObject = JSON.parse(oidcString) as { [name: string]: any };
console.log(oidcObject);
const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri};
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
console.log(`we have new session UUID: ${newSessionUuid}`);
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setLoggedInUser(values?.user);
console.log("Token load complete.");
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
}
else
{
////////////////////////////////////////////
// if unrecognized state, render an error //
////////////////////////////////////////////
setEarlyReturnForAuth(<div>Login error: Unrecognized state. Refresh to try again.</div>);
}
}
else
{
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
if (sessionUuid)
{
console.log(`we have session UUID: ${sessionUuid} - validating it...`);
const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setLoggedInUser(values?.user);
console.log("Token load complete.");
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////////
// else no cookie, and not a token url, we need to redirect to the provider's login page //
// capture the path the user was trying to access in local storage, to redirect back to later. //
/////////////////////////////////////////////////////////////////////////////////////////////////
console.log("Loading token from OAuth2 provider...");
console.log(authOidc);
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc?.signinRedirect();
}
}
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc?.signoutRedirect();
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
const authority: string = authenticationMetaData.data.baseUrl;
const clientId = authenticationMetaData.data.clientId;
if (!authority || !clientId)
{
return (
<div>Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].</div>
);
}
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
response_type: "code",
scope: "openid profile email offline_access",
};
return (<AuthProvider {...oidcConfig}>
{children}
</AuthProvider>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -23,8 +23,10 @@ import {Chip} from "@mui/material";
import TextField from "@mui/material/TextField";
import {makeStyles} from "@mui/styles";
import Downshift from "downshift";
import {debounce} from "lodash";
import {arrayOf, func, string} from "prop-types";
import React, {useEffect, useState} from "react";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useRef, useState} from "react";
const useStyles = makeStyles((theme: any) => ({
chip: {
@ -34,21 +36,107 @@ const useStyles = makeStyles((theme: any) => ({
function ChipTextField({...props})
{
const qController = Client.getInstance();
const classes = useStyles();
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const {table, field, handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const [inputValue, setInputValue] = useState("");
const [chips, setChips] = useState([]);
const [chipColors, setChipColors] = useState([]);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [isMakingRequest, setIsMakingRequest] = useState(false);
////////////////////////////////////////////////////////////////////
// these refs are used for the async api call for possible values //
////////////////////////////////////////////////////////////////////
const chipsRef = useRef<string[]>([]);
/////////////////////////////////////////////////////////////////////////////////////////////
// use debounce library to not flood server as user types, wait a second before requesting //
/////////////////////////////////////////////////////////////////////////////////////////////
async function fetchPVSLabelsAndColorChips()
{
//////////////////////////////////////////////////////////
// make a request for the possible value labels (chips) //
//////////////////////////////////////////////////////////
setIsMakingRequest(true);
const currentChips = chipsRef.current;
setChipColors([]);
///////////////////////////////////////////////////////////////////////////////
// Determine chip colors based on whether each chip value appears in results //
///////////////////////////////////////////////////////////////////////////////
const newChipColors = [] as string[];
const chipValidity = [] as boolean[];
const chipPVSIds = [] as any[];
////////////////////////////////////////////////////////////////////////////
// make the request for all 'chips' with pagination to handle large sizes //
////////////////////////////////////////////////////////////////////////////
const BATCH_SIZE = 250;
for (let i = 0; i < currentChips.length; i += BATCH_SIZE)
{
const batch = currentChips.slice(i, i + BATCH_SIZE);
const page = await qController.possibleValues(
table.name,
null,
field.name,
"",
null,
batch
);
for (let j = 0; j < batch.length; j++)
{
let found = false;
for (let k = 0; k < page.length; k++)
{
const result = page[k];
if (result.label.toLowerCase() === batch[j].toLowerCase())
{
chipPVSIds.push(result.id);
newChipColors.push("info");
chipValidity.push(true);
found = true;
break;
}
}
if (!found)
{
chipPVSIds.push(null);
chipValidity.push(false);
newChipColors.push("error");
}
}
}
setChipPVSIds(chipPVSIds);
setChipColors(newChipColors);
setChipValidity(chipValidity);
setIsMakingRequest(false);
}
const debouncedApiCall = useRef(debounce(fetchPVSLabelsAndColorChips, 500)).current;
useEffect(() =>
{
setChips(chipData);
}, [chipData]);
chipsRef.current = chipData;
determineChipColors();
if (chipType !== "pvs")
{
const currentChipValidity = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i])))
);
setChipValidity(currentChipValidity);
}
}, [JSON.stringify(chipData), chips]);
useEffect(() =>
{
handleChipChange(chips);
}, [chips, handleChipChange]);
handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
}, [chipValidity, chipPVSIds, isMakingRequest]);
function handleKeyDown(event: any)
{
@ -64,13 +152,16 @@ function ChipTextField({...props})
setInputValue("");
return;
}
if (!event.target.value.replace(/\s/g, "").length) return;
if (!event.target.value.replace(/\s/g, "").length)
{
return;
}
setInputValue("");
newChipList.push(event.target.value.trim());
setChips(newChipList);
setInputValue("");
}
else if (chips.length && !inputValue.length && event.key === "Backspace" )
else if (chips.length && !inputValue.length && event.key === "Backspace")
{
setChips(chips.slice(0, chips.length - 1));
}
@ -87,18 +178,26 @@ function ChipTextField({...props})
setChips(newChipList);
}
const handleDelete = (item: any) => () =>
{
const newChipList = [...chips];
newChipList.splice(newChipList.indexOf(item), 1);
setChips(newChipList);
};
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
{
setInputValue(event.target.value);
}
function determineChipColors(): any
{
if (chipType === "pvs")
{
debouncedApiCall();
}
else
{
const newChipColors = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i]))) ? "info" : "error"
);
setChipColors(newChipColors);
}
}
return (
<React.Fragment>
@ -116,7 +215,7 @@ function ChipTextField({...props})
});
// @ts-ignore
return (
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
<div id="chip-text-field-container" style={{flexWrap: "wrap", display: "flex"}}>
<TextField
sx={{width: "99%"}}
disabled={disabled}
@ -125,16 +224,16 @@ function ChipTextField({...props})
startAdornment:
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
{
chips.map((item, i) => (
chips.map((item, index) => (
<Chip
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
key={`${item}-${i}`}
onChange={determineChipColors}
color={chipColors[index]}
key={`${item}-${index}`}
variant="outlined"
tabIndex={-1}
label={item}
className={classes.chip}
/>
))
}
</div>,
@ -158,6 +257,7 @@ function ChipTextField({...props})
</React.Fragment>
);
}
ChipTextField.defaultProps = {
chipData: []
};
@ -166,4 +266,4 @@ ChipTextField.propTypes = {
chipData: arrayOf(string)
};
export default ChipTextField
export default ChipTextField;

View File

@ -20,15 +20,21 @@
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import {useFormikContext} from "formik";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import FileInputField from "qqq/components/forms/FileInputField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import React from "react";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
const qController = Client.getInstance();
interface Props
{
@ -43,7 +49,12 @@ interface Props
function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler, record, helpRoles, helpContentKeyPrefix}: Props): JSX.Element
{
const {formFields, values, errors, touched} = formData;
const {formFields: origFormFields, errors, touched} = formData;
const {setFieldValue, values} = useFormikContext<Record<string, any>>();
const [formAdjustmentCounter, setFormAdjustmentCounter] = useState(0)
const [formFields, setFormFields] = useState(origFormFields as {[key: string]: any});
const bulkEditSwitchChanged = (name: string, value: boolean) =>
{
@ -51,13 +62,211 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
};
/////////////////////////////////////////
// run on-load handlers if we have any //
/////////////////////////////////////////
useEffect(() =>
{
for (let fieldName in formFields)
{
const field = formFields[fieldName];
const materialDashboardFieldMetaData = field.fieldMetaData?.supplementalFieldMetaData?.get("materialDashboard");
if(materialDashboardFieldMetaData?.onLoadFormAdjuster)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo consider cases with multiple - do they need to list a sequenceNo? do they need to run serially? //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
considerRunningFormAdjuster("onLoad", fieldName, values[fieldName]);
}
}
}, []);
/***************************************************************************
**
***************************************************************************/
const handleFieldChange = async (fieldName: string, newValue: any) =>
{
const field = formFields[fieldName];
if (!field)
{
return;
}
//////////////////////////////////////////////////////////////////////
// map possible-value objects to ids - also capture their labels... //
//////////////////////////////////////////////////////////////////////
let actualNewValue = newValue;
let possibleValueLabel: string = null;
if (field.possibleValueProps)
{
actualNewValue = newValue ? newValue.id : null;
possibleValueLabel = newValue ? newValue.label : null;
}
/////////////////////////////////////////////////////////////////////////////////////////////
// make sure formik has the value - and that we capture the possible-value label if needed //
/////////////////////////////////////////////////////////////////////////////////////////////
setFieldValue(fieldName, actualNewValue);
if (field.possibleValueProps)
{
field.possibleValueProps.initialDisplayValue = possibleValueLabel;
}
///////////////////////////////////////////
// run onChange adjuster if there is one //
///////////////////////////////////////////
considerRunningFormAdjuster("onChange", fieldName, actualNewValue);
}
/***************************************************************************
**
***************************************************************************/
const considerRunningFormAdjuster = async (event: "onChange" | "onLoad", fieldName: string, newValue: any) =>
{
const field = formFields[fieldName];
if (!field)
{
return;
}
const materialDashboardFieldMetaData = field.fieldMetaData?.supplementalFieldMetaData?.get("materialDashboard");
const adjuster = event == "onChange" ? materialDashboardFieldMetaData?.onChangeFormAdjuster : materialDashboardFieldMetaData?.onLoadFormAdjuster;
if (!adjuster)
{
return;
}
console.log(`Running form adjuster for field ${fieldName} ${event} (value is: ${newValue})`);
//////////////////////////////////////////////////////////////////
// disable fields temporarily while waiting on backend response //
//////////////////////////////////////////////////////////////////
const fieldNamesToTempDisable: string[] = materialDashboardFieldMetaData?.fieldsToDisableWhileRunningAdjusters ?? []
const previousIsEditableValues: {[key: string]: boolean} = {};
if(fieldNamesToTempDisable.length > 0)
{
for (let oldFieldName in formFields)
{
if (fieldNamesToTempDisable.indexOf(oldFieldName) > -1)
{
previousIsEditableValues[oldFieldName] = formFields[oldFieldName].isEditable;
formFields[oldFieldName].isEditable = false;
}
}
setFormAdjustmentCounter(formAdjustmentCounter + 1);
setFormFields({...formFields});
}
////////////////////////////////////////////////////
// build request to backend for field adjustments //
////////////////////////////////////////////////////
const postBody = new FormData();
postBody.append("event", event);
postBody.append("fieldName", fieldName);
postBody.append("newValue", newValue);
postBody.append("allValues", JSON.stringify(values));
const response = await qController.axiosRequest(
{
method: "post",
url: `/material-dashboard-backend/form-adjuster/${encodeURIComponent(materialDashboardFieldMetaData.formAdjusterIdentifier)}/${event}`,
data: postBody,
headers: qController.defaultMultipartFormDataHeaders()
});
console.log("Form adjuster response: " + JSON.stringify(response));
////////////////////////////////////////////////////
// un-disable any temp disabled fields from above //
////////////////////////////////////////////////////
if(fieldNamesToTempDisable.length > 0)
{
for (let oldFieldName in formFields)
{
if (fieldNamesToTempDisable.indexOf(oldFieldName) > -1)
{
formFields[oldFieldName].isEditable = previousIsEditableValues[oldFieldName];
}
}
setFormFields({...formFields});
}
///////////////////////////////////////////////////
// replace field definitions, if we have updates //
///////////////////////////////////////////////////
const updatedFields: { [fieldName: string]: QFieldMetaData } = response.updatedFieldMetaData;
if(updatedFields)
{
for (let updatedFieldName in updatedFields)
{
const updatedField = new QFieldMetaData(updatedFields[updatedFieldName]);
const dynamicField = DynamicFormUtils.getDynamicField(updatedField); // todo dynamicallyDisabledFields? second param...
const dynamicFieldInObject: any = {};
dynamicFieldInObject[updatedFieldName] = dynamicField;
let tableName = null;
let processName = null;
let displayValues = new Map();
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [updatedFields[updatedFieldName]], tableName, processName, displayValues);
for (let oldFieldName in formFields)
{
if (oldFieldName == updatedFieldName)
{
formFields[updatedFieldName] = dynamicField;
}
}
}
setFormAdjustmentCounter(formAdjustmentCounter + 2);
setFormFields({...formFields});
}
/////////////////////////
// update field values //
/////////////////////////
const updatedFieldValues: {[fieldName: string]: any} = response?.updatedFieldValues ?? {};
for (let fieldNameToUpdate in updatedFieldValues)
{
setFieldValue(fieldNameToUpdate, updatedFieldValues[fieldNameToUpdate]);
///////////////////////////////////////////////////////////////////////////////////////
// todo - track if a pvs field gets a value, but not a display value, and fetch it?? //
///////////////////////////////////////////////////////////////////////////////////////
}
/////////////////////////////////////////////////
// set display values in PVS's if we have them //
/////////////////////////////////////////////////
const updatedFieldDisplayValues: {[fieldName: string]: any} = response?.updatedFieldDisplayValues ?? {};
for (let fieldNameToUpdate in updatedFieldDisplayValues)
{
const fieldToUpdate = formFields[fieldNameToUpdate];
if(fieldToUpdate?.possibleValueProps)
{
fieldToUpdate.possibleValueProps.initialDisplayValue = updatedFieldDisplayValues[fieldNameToUpdate];
}
}
////////////////////////////////////////
// clear field values if we have them //
////////////////////////////////////////
const fieldsToClear: string[] = response?.fieldsToClear ?? [];
for (let fieldToClear of fieldsToClear)
{
setFieldValue(fieldToClear, "");
}
};
return (
<Box>
<Box lineHeight={0}>
<MDTypography variant="h5">{formLabel}</MDTypography>
</Box>
<Box mt={1.625}>
<Grid container spacing={3}>
<Grid container lg={12} display="flex" spacing={3}>
{formFields
&& Object.keys(formFields).length > 0
&& Object.keys(formFields).map((fieldName: any) =>
@ -68,19 +277,22 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
return null;
}
const display = field.fieldMetaData?.isHidden ? "none" : "initial";
if (values[fieldName] === undefined)
{
values[fieldName] = "";
}
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if(formattedHelpContent)
if (formattedHelpContent)
{
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
}
const labelElement = <DynamicFormFieldLabel name={field.name} label={field.label} />;
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
let itemXS = 12;
let itemSM = 6;
@ -92,13 +304,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const width = fileUploadAdornment?.values?.get("width") ?? "half";
if(width == "full")
if (width == "full")
{
itemSM = 12;
itemLG = 12;
}
return (
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName + "-" + formAdjustmentCounter}>
{labelElement}
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
</Grid>
@ -114,10 +327,10 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
Object.keys(values).forEach((key) =>
{
otherValuesMap.set(key, values[key]);
})
});
return (
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
<Grid item display={display} lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName + "-" + formAdjustmentCounter}>
{labelElement}
<DynamicSelect
fieldPossibleValueProps={field.possibleValueProps}
@ -128,6 +341,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap}
useCase="form"
onChange={(newValue: any) => handleFieldChange(fieldName, newValue)}
/>
{formattedHelpContent}
</Grid>
@ -138,7 +352,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
// everything else!! //
///////////////////////
return (
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
<Grid item display={display} lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName + "-" + formAdjustmentCounter}>
{labelElement}
<QDynamicFormField
id={field.name}
@ -153,6 +367,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
success={`${values[fieldName]}` !== "" && !errors[fieldName] && touched[fieldName]}
formFieldObject={field}
onChangeCallback={(newValue) => handleFieldChange(fieldName, newValue)}
/>
{formattedHelpContent}
</Grid>

View File

@ -20,17 +20,18 @@
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import {flushSync} from "react-dom";
// Declaring props types for FormField
@ -83,10 +84,10 @@ function QDynamicFormField({
if (placeholder)
{
inputProps.placeholder = placeholder
inputProps.placeholder = placeholder;
}
if(backgroundColor)
if (backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
@ -124,7 +125,7 @@ function QDynamicFormField({
{
onChange.onChange = (e: any) =>
{
if(isToUpperCase || isToLowerCase)
if (isToUpperCase || isToLowerCase)
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
@ -141,7 +142,10 @@ function QDynamicFormField({
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
onChangeCallback(newValue);
if (onChangeCallback)
{
onChangeCallback(newValue);
}
});
const input = document.getElementById(name) as HTMLInputElement;
@ -150,7 +154,7 @@ function QDynamicFormField({
input.setSelectionRange(beforeStart, beforeEnd);
}
}
else if(onChangeCallback)
else if (onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
@ -162,15 +166,15 @@ function QDynamicFormField({
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if(onChangeCallback)
if (onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id)
onChangeCallback(newValue == null ? null : newValue.id);
}
}
let field;
let getsBulkEditHtmlLabel = true;
if(formFieldObject.possibleValueProps)
if (formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
@ -183,7 +187,7 @@ function QDynamicFormField({
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>)
/>);
}
else if (type === "checkbox")
{
@ -217,7 +221,7 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if(onChangeCallback)
if (onChangeCallback)
{
onChangeCallback(value);
}

View File

@ -191,6 +191,11 @@ class DynamicFormUtils
props.possibleValueSourceName = field.possibleValueSourceName;
}
if(field.possibleValueSourceFilter)
{
props.possibleValueSourceFilter = field.possibleValueSourceFilter;
}
dynamicFormFields[field.name].possibleValueProps = props;
}
}

View File

@ -174,7 +174,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
}
};
/***************************************************************************
@ -182,15 +182,24 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if(possibleValues)
if (possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues)
return filterInlinePossibleValues(searchTerm, possibleValues);
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
return await qController.possibleValues(
{
tableName,
processName,
fieldNameOrPossibleValueSourceName: possibleValueSourceName ?? fieldName,
searchTerm: searchTerm ?? "",
values: otherValues,
useCase,
possibleValueSourceFilter: fieldPossibleValueProps.possibleValueSourceFilter
});
}
}
};
/***************************************************************************

View File

@ -59,15 +59,17 @@ import * as Yup from "yup";
interface Props
{
id?: string;
isModal: boolean;
table?: QTableMetaData;
closeModalHandler?: (event: object, reason: string) => void;
defaultValues: { [key: string]: string };
disabledFields: { [key: string]: boolean } | string[];
isCopy?: boolean;
onSubmitCallback?: (values: any) => void;
overrideHeading?: string;
id?: string,
isModal: boolean,
table?: QTableMetaData,
closeModalHandler?: (event: object, reason: string) => void,
defaultValues: { [key: string]: string },
disabledFields: { [key: string]: boolean } | string[],
isCopy?: boolean,
onSubmitCallback?: (values: any, tableName: string) => void,
overrideHeading?: string,
saveButtonLabel?: string,
saveButtonIcon?: string,
}
EntityForm.defaultProps = {
@ -79,6 +81,8 @@ EntityForm.defaultProps = {
disabledFields: {},
isCopy: false,
onSubmitCallback: null,
saveButtonLabel: "Save",
saveButtonIcon: "save",
};
@ -173,7 +177,7 @@ function EntityForm(props: Props): JSX.Element
*******************************************************************************/
function openAddChildRecord(name: string, widgetData: any)
{
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let defaultValues = widgetData.defaultValuesForNewChildRecords || {};
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
@ -181,6 +185,18 @@ function EntityForm(props: Props): JSX.Element
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if (widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
for (let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValues[childField] = formValues[parentField];
}
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
}
@ -208,7 +224,7 @@ function EntityForm(props: Props): JSX.Element
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
};
}
/*******************************************************************************
@ -243,16 +259,16 @@ function EntityForm(props: Props): JSX.Element
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any)
function submitEditChildForm(values: any, tableName: string)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values, tableName);
}
/*******************************************************************************
**
*******************************************************************************/
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any, childTableName?: string)
{
const metaData = await qController.loadMetaData();
const widgetMetaData = metaData.widgets.get(widgetName);
@ -263,13 +279,38 @@ function EntityForm(props: Props): JSX.Element
newChildListWidgetData[widgetName].queryOutput.records = [];
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// build a map of display values for the new record, specifically, for any possible-values that need translated. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const displayValues: { [fieldName: string]: string } = {};
if (childTableName && values)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const childTableMetaData = await qController.loadTableMetaData(childTableName);
for (let key in values)
{
const value = values[key];
const field = childTableMetaData.fields.get(key);
if (field.possibleValueSourceName)
{
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], null, objectToMap(values), "form");
if (possibleValues && possibleValues.length > 0)
{
displayValues[key] = possibleValues[0].label;
}
}
}
}
switch (action)
{
case "insert":
newChildListWidgetData[widgetName].queryOutput.records.push({values: values});
newChildListWidgetData[widgetName].queryOutput.records.push({values: values, displayValues: displayValues});
break;
case "edit":
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values};
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values, displayValues: displayValues};
break;
case "delete":
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
@ -407,6 +448,7 @@ function EntityForm(props: Props): JSX.Element
widgetMetaData={widgetMetaData}
widgetData={widgetData}
recordValues={formValues}
label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label}
onSaveCallback={setFormFieldValuesFromWidget}
/>;
}
@ -478,6 +520,25 @@ function EntityForm(props: Props): JSX.Element
}
/***************************************************************************
**
***************************************************************************/
function objectToMap(object: { [key: string]: any }): Map<string, any>
{
if (object == null)
{
return (null);
}
const rs = new Map<string, any>();
for (let key in object)
{
rs.set(key, object[key]);
}
return rs;
}
//////////////////
// initial load //
//////////////////
@ -595,18 +656,24 @@ function EntityForm(props: Props): JSX.Element
if (defaultValue)
{
initialValues[fieldName] = defaultValue;
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// we need to set the initialDisplayValue for possible value fields with a default value //
// so, look them up here now if needed //
///////////////////////////////////////////////////////////////////////////////////////////
if (fieldMetaData.possibleValueSourceName)
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// do a second loop, this time looking up display-values for any possible-value fields with a default value //
// do it in a second loop, to pass in all the other values (from initialValues), in case there's a PVS filter that needs them. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
for (let i = 0; i < fieldArray.length; i++)
{
const fieldMetaData = fieldArray[i];
const fieldName = fieldMetaData.name;
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue && fieldMetaData.possibleValueSourceName)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], null, objectToMap(initialValues), "form");
if (results && results.length > 0)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], undefined, "form");
if (results && results.length > 0)
{
defaultDisplayValues.set(fieldName, results[0].label);
}
defaultDisplayValues.set(fieldName, results[0].label);
}
}
}
@ -823,7 +890,7 @@ function EntityForm(props: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (props.onSubmitCallback)
{
props.onSubmitCallback(values);
props.onSubmitCallback(values, tableName);
return;
}
@ -1268,12 +1335,14 @@ function EntityForm(props: Props): JSX.Element
</Box>
)) : null}
<Box component="div" p={3}>
<Grid container justifyContent="flex-end" spacing={3}>
<QCancelButton onClickHandler={props.isModal ? props.closeModalHandler : handleCancelClicked} disabled={isSubmitting} />
<QSaveButton disabled={isSubmitting} />
</Grid>
</Box>
{formFields &&
<Box component="div" p={3} className={props.isModal ? "modalBottomButtonBar" : "stickyBottomButtonBar"}>
<Grid container justifyContent="flex-end" spacing={3}>
<QCancelButton onClickHandler={props.isModal ? props.closeModalHandler : handleCancelClicked} disabled={isSubmitting} />
<QSaveButton disabled={isSubmitting} label={props.saveButtonLabel} iconName={props.saveButtonIcon} />
</Grid>
</Box>
}
</Form>
);
@ -1292,6 +1361,8 @@ function EntityForm(props: Props): JSX.Element
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={props.onSubmitCallback ? props.onSubmitCallback : submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
saveButtonLabel="OK"
saveButtonIcon="check"
/>
</div>
</Modal>

View File

@ -270,7 +270,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>

View File

@ -20,21 +20,22 @@
*/
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {Button} from "@mui/material";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import Link from "@mui/material/Link";
import List from "@mui/material/List";
import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
import SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse";
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot";
import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
import MDTypography from "qqq/components/legacy/MDTypography";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
interface Props
@ -44,6 +45,7 @@ interface Props
logo?: string;
appName?: string;
branding?: QBrandingMetaData;
logout: () => void;
routes: {
[key: string]:
| ReactNode
@ -66,7 +68,7 @@ interface Props
[key: string]: any;
}
function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element
function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element
{
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
@ -257,7 +259,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
active={key === collapseName}
open={openCollapse === key}
noCollapse={noCollapse}
onClick={() => (! noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null) }
onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)}
>
{collapse ? renderCollapse(collapse) : null}
</SideNavCollapse>
@ -300,6 +302,30 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
}
);
/***************************************************************************
**
***************************************************************************/
function EnvironmentBanner({branding}: { branding: QBrandingMetaData }): JSX.Element | null
{
// deprecated!
if (branding && branding.environmentBannerText)
{
return <Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>;
}
const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO");
if (banner)
{
return <Box className={getBannerClassName(banner)} mt={2} borderRadius={2} sx={getBannerStyles(banner)}>
{makeBannerContent(banner)}
</Box>;
}
return (null);
}
return (
<SidenavRoot
{...rest}
@ -330,12 +356,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
</Box>
}
</Box>
{
branding && branding.environmentBannerText &&
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>
}
<EnvironmentBanner branding={branding} />
</Box>
<Divider
light={
@ -350,7 +371,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
(darkMode && !transparentSidenav && whiteSidenav)
}
/>
<AuthenticationButton />
<Button onClick={logout}>Log Out</Button>
</SidenavRoot>
);
}

View File

@ -97,6 +97,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
margin: "0",
borderRadius: "0",
height: "100%",
top: "unset",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
},

View File

@ -0,0 +1,97 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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 {Banner} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Banner";
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import parse from "html-react-parser";
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// One may render a banner using the functions in this file as: //
// //
// const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO"); //
// return (<Box className={getBannerClassName(banner)} sx={{padding: "1rem", ...getBannerStyles(banner)}}> //
// {makeBannerContent(banner)} //
// </Box>); //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
/***************************************************************************
**
***************************************************************************/
export function getBanner(branding: QBrandingMetaData, slot: string): Banner | null
{
if (branding?.banners?.has(slot))
{
return (branding.banners.get(slot));
}
return (null);
}
/***************************************************************************
**
***************************************************************************/
export function getBannerStyles(banner: Banner)
{
let bgColor = "";
let color = "";
if (banner)
{
if (banner.backgroundColor)
{
bgColor = banner.backgroundColor;
}
if (banner.textColor)
{
bgColor = banner.textColor;
}
}
const rest = banner?.additionalStyles ?? {};
return ({
backgroundColor: bgColor,
color: color,
...rest
});
}
/***************************************************************************
**
***************************************************************************/
export function getBannerClassName(banner: Banner)
{
return `banner ${banner?.severity?.toLowerCase()}`;
}
/***************************************************************************
**
***************************************************************************/
export function makeBannerContent(banner: Banner): JSX.Element
{
return <>{banner?.messageHTML ? parse(banner?.messageHTML) : banner?.messageText}</>;
}

View File

@ -20,6 +20,7 @@
*/
import {QExposedJoin} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QExposedJoin";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
@ -27,25 +28,26 @@ import {Box} from "@mui/material";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import React, {ReactNode, useState} from "react";
import React, {ReactNode, useMemo, useState} from "react";
interface FieldAutoCompleteProps
{
id: string;
metaData: QInstance;
tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string };
autoFocus?: boolean;
forceOpen?: boolean;
hiddenFieldNames?: string[];
availableFieldNames?: string[];
variant?: "standard" | "filled" | "outlined";
label?: string;
textFieldSX?: any;
autocompleteSlotProps?: any;
hasError?: boolean;
noOptionsText?: string;
id: string,
metaData: QInstance,
tableMetaData: QTableMetaData,
handleFieldChange: (event: any, newValue: any, reason: string) => void,
defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string },
autoFocus?: boolean,
forceOpen?: boolean,
hiddenFieldNames?: string[],
availableFieldNames?: string[],
variant?: "standard" | "filled" | "outlined",
label?: string,
textFieldSX?: any,
autocompleteSlotProps?: any,
hasError?: boolean,
noOptionsText?: string,
omitExposedJoins?: string[]
}
FieldAutoComplete.defaultProps =
@ -88,7 +90,7 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
/*******************************************************************************
** 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
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText, omitExposedJoins}: FieldAutoCompleteProps): JSX.Element
{
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
@ -96,11 +98,25 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
const availableExposedJoins = useMemo(() =>
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
const rs: QExposedJoin[] = []
for(let exposedJoin of tableMetaData.exposedJoins ?? [])
{
const exposedJoin = tableMetaData.exposedJoins[i];
if(omitExposedJoins?.indexOf(exposedJoin.joinTable.name) > -1)
{
continue;
}
rs.push(exposedJoin);
}
return (rs);
}, [tableMetaData, omitExposedJoins]);
if (availableExposedJoins && availableExposedJoins.length > 0)
{
for (let i = 0; i < availableExposedJoins.length; i++)
{
const exposedJoin = availableExposedJoins[i];
if (metaData.tables.has(exposedJoin.joinTable.name))
{
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
@ -185,7 +201,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
{originalEndAdornment}
</Box>;
return (<TextField {...params} autoFocus={autoFocus} label={label} variant={variant} sx={textFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
return (<TextField {...params} autoFocus={autoFocus} label={label} variant={variant} sx={textFieldSX} autoComplete="off" type="search" InputProps={inputProps} />);
}}
// @ts-ignore
defaultValue={defaultValue}

View File

@ -20,6 +20,7 @@
*/
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 {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
@ -35,12 +36,11 @@ import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import {any} from "prop-types";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import Client from "qqq/utils/qqq/Client";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
interface Props
{
@ -162,8 +162,8 @@ function GotoRecordDialog(props: Props): JSX.Element
/***************************************************************************
** event handler for close button
***************************************************************************/
** event handler for close button
***************************************************************************/
const closeRequested = () =>
{
if (props.mayClose)
@ -182,23 +182,23 @@ function GotoRecordDialog(props: Props): JSX.Element
options[optionIndex].forEach((field) =>
{
if(values[field.name])
if (values[field.name])
{
anyFieldsInThisOptionHaveAValue = true;
}
})
});
if(!anyFieldsInThisOptionHaveAValue)
if (!anyFieldsInThisOptionHaveAValue)
{
return (true);
}
return (false);
}
};
/***************************************************************************
** event handler for clicking an 'option's go/submit button
***************************************************************************/
** event handler for clicking an 'option's go/submit button
***************************************************************************/
const optionGoClicked = async (optionIndex: number) =>
{
setError("");
@ -207,9 +207,13 @@ function GotoRecordDialog(props: Props): JSX.Element
const queryStringParts: string[] = [];
options[optionIndex].forEach((field) =>
{
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]))
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`)
})
if (field.type == QFieldType.STRING && !values[field.name])
{
return;
}
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]));
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`);
});
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);
@ -223,7 +227,7 @@ function GotoRecordDialog(props: Props): JSX.Element
}
else if (queryResult.length == 1)
{
if(options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
if (options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
{
/////////////////////////////////////////////////
// navigate by pkey, if that's how we searched //

View File

@ -21,12 +21,11 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {Checkbox, FormControlLabel, Radio} from "@mui/material";
import {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
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 IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
@ -34,6 +33,7 @@ import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface BulkLoadMappingFieldProps
@ -45,6 +45,29 @@ interface BulkLoadMappingFieldProps
forceParentUpdate?: () => void,
}
const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.5rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "30px",
minWidth: "30px",
height: "2rem",
minHeight: "2rem",
paddingLeft: 0,
paddingRight: 0,
marginRight: "0.5rem",
marginTop: "0.5rem",
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
@ -54,6 +77,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
const [valueType, setValueType] = useState(bulkLoadField.valueType);
const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
const [selectedColumnInputValue, setSelectedColumnInputValue] = useState(columnNames[bulkLoadField.columnIndex]);
const [doingInitialLoadOfPossibleValue, setDoingInitialLoadOfPossibleValue] = useState(false);
const [everDidInitialLoadOfPossibleValue, setEverDidInitialLoadOfPossibleValue] = useState(false);
const [possibleValueInitialDisplayValue, setPossibleValueInitialDisplayValue] = useState(null as string);
const fieldMetaData = new QFieldMetaData(bulkLoadField.field);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
@ -61,18 +89,68 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
dynamicFieldInObject[fieldMetaData["name"]] = dynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [fieldMetaData], bulkLoadField.tableStructure.tableName, null, null);
/////////////////////////////////////////////////////////////////////////////////////
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if (dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
setEverDidInitialLoadOfPossibleValue(true);
(async () =>
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, null, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
}
else
{
setPossibleValueInitialDisplayValue(null);
}
}
catch (e)
{
console.log(`Error loading possible value: ${e}`);
}
actuallyDoingInitialLoadOfPossibleValue = false;
setDoingInitialLoadOfPossibleValue(false);
})();
}
if (dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
//////////////////////////////////////////////////////
// build array of options for the columns drop down //
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: { [label: string]: boolean } = {};
for (let i = 0; i < columnNames.length; i++)
{
columnOptions.push({label: columnNames[i], value: i});
const label = columnNames[i];
if (!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
}
}
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
if(bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
if (bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
const mainFontSize = "0.875rem";
@ -98,6 +176,8 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
function columnChanged(event: any, newValue: any, reason: string)
{
setSelectedColumn(newValue);
setSelectedColumnInputValue(newValue == null ? "" : newValue.label);
bulkLoadField.columnIndex = newValue == null ? null : newValue.value;
if (fileDescription.hasHeaderRow)
@ -106,6 +186,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
}
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
@ -118,6 +199,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
bulkLoadField.defaultValue = newValue;
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
@ -131,6 +213,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
bulkLoadField.valueType = newValueType;
setValueType(newValueType);
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
@ -144,7 +227,15 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
forceParentUpdate && forceParentUpdate();
}
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}}>
/***************************************************************************
**
***************************************************************************/
function changeSelectedColumnInputValue(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
{
setSelectedColumnInputValue(e.target.value);
}
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}} id={`blfmf-${bulkLoadField.field.name}`}>
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
{
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
@ -152,7 +243,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box display="flex" alignItems="flex-start">
{
(!isRequired) && <IconButton onClick={() => removeFieldCallback()} sx={{pt: "0.75rem"}}><Icon fontSize="small">remove_circle</Icon></IconButton>
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
</Tooltip>
}
<Box pt="0.625rem">
{bulkLoadField.getQualifiedLabel()}
@ -167,13 +260,13 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
valueType == "column" && <Box width="100%">
<Autocomplete
id={bulkLoadField.field.name}
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumn?.label} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumnInputValue} onChange={e => changeSelectedColumnInputValue(e)} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
fullWidth
options={columnOptions}
multiple={false}
defaultValue={selectedColumn}
value={selectedColumn}
inputValue={selectedColumn?.label}
inputValue={selectedColumnInputValue}
onChange={columnChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
@ -186,7 +279,10 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "defaultValue" && <Box width="100%">
valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
}
{
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
<QDynamicFormField
name={`${bulkLoadField.field.name}.defaultValue`}
displayFormat={""}
@ -200,9 +296,15 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
}
</Box>
</Box>
{
bulkLoadField.warning &&
<Box fontSize={smallerFontSize} color={colors.warning.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.warning}
</Box>
}
{
bulkLoadField.error &&
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px">
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}

View File

@ -47,7 +47,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceRerender, setForceRerender] = useState(0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
@ -98,8 +98,9 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping]);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
@ -140,9 +141,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
// addFieldsToggleStates[bulkLoadField.getQualifiedName()] = false;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
@ -160,7 +158,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceRerender(forceRerender + 1);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
@ -297,7 +295,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceRerender}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>

View File

@ -20,45 +20,56 @@
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Badge, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
interface BulkLoadMappingFormProps
{
processValues: any;
tableMetaData: QTableMetaData;
metaData: QInstance;
setActiveStepLabel: (label: string) => void;
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance,
setActiveStepLabel: (label: string) => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** process component - screen where user does a bulk-load file mapping.
***************************************************************************/
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel}: BulkLoadMappingFormProps, ref) =>
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
{
const {setFieldValue} = useFormikContext();
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(null as QRecord);
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(currentSavedBulkLoadProfile));
const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string });
const [noMappedFieldsError, setNoMappedFieldsError] = useState(null as string);
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
@ -119,18 +130,31 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
}
setFieldErrors(fieldErrors);
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
setNoMappedFieldsError("You must have at least 1 field.");
haveLocalErrors = true;
setTimeout(() => setNoMappedFieldsError(null), 2500);
}
else
{
setNoMappedFieldsError(null);
}
if(haveProfileErrors)
{
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
}
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
}
};
});
useEffect(() =>
{
console.log("@dk has header row changed!");
}, [bulkLoadMapping.hasHeaderRow]);
/***************************************************************************
**
***************************************************************************/
@ -214,6 +238,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
tableStructure={tableStructure}
fileName={processValues.fileBaseName}
fieldErrors={fieldErrors}
frontendStep={frontendStep}
processMetaData={processMetaData}
forceParentUpdate={() => forceUpdate()}
/>
@ -221,8 +247,15 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
<BulkLoadFileMappingFields
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() => forceUpdate()}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
{
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
}
</Box>
</Box>);
@ -232,8 +265,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
fileDescription: FileDescription,
@ -241,13 +272,15 @@ interface BulkLoadMappingHeaderProps
bulkLoadMapping?: BulkLoadMapping,
fieldErrors: { [fieldName: string]: string },
tableStructure: BulkLoadTableStructure,
forceParentUpdate?: () => void
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate}: BulkLoadMappingHeaderProps): JSX.Element
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
@ -261,8 +294,6 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
const layoutOptions = [
{label: "Flat", id: "FLAT"},
{label: "Tall", id: "TALL"},
@ -276,27 +307,55 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
/***************************************************************************
**
***************************************************************************/
function hasHeaderRowChanged(newValue: any)
{
bulkLoadMapping.hasHeaderRow = newValue;
fileDescription.hasHeaderRow = newValue;
bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription);
fieldErrors.hasHeaderRow = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function layoutChanged(event: any, newValue: any)
{
bulkLoadMapping.layout = newValue ? newValue.id : null;
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function getFormattedHelpContent(fieldName: string): JSX.Element
{
const field = frontendStep?.formFields?.find(f => f.name == fieldName);
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
let formattedHelpContent = <HelpContent helpContents={field?.helpContents} roles={helpRoles} helpContentKey={`process:${processMetaData?.name};field:${fieldName}`} />;
if (formattedHelpContent)
{
const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem";
return <Box color="#757575" fontSize="0.875rem" mt={mt}>{formattedHelpContent}</Box>;
}
return null;
}
return (
<Box>
<h5>File Details</h5>
<Box ml="1rem">
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
@ -307,6 +366,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
</MDTypography>
}
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
@ -320,6 +380,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
@ -328,6 +389,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</Grid>
</Grid>
</Box>
@ -336,16 +398,16 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
}
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription;
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePreviewProps): JSX.Element
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
@ -353,25 +415,145 @@ function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePrevie
rows.push(i);
}
/***************************************************************************
**
***************************************************************************/
function getValue(i: number, j: number)
{
const value = fileDescription.bodyValuesPreview[j][i];
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
if (value && value.string)
{
// @ts-ignore
return (value.string);
}
return `${value}`;
}
/***************************************************************************
**
***************************************************************************/
function getHeaderColor(count: number): string
{
if (count > 0)
{
return "blue";
}
return "black";
}
/***************************************************************************
**
***************************************************************************/
function getCursor(count: number): string
{
if (count > 0)
{
return "pointer";
}
return "default";
}
/***************************************************************************
**
***************************************************************************/
function getColumnTooltip(fields: BulkLoadField[])
{
return (<Box>
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
<ul style={{marginLeft: "1rem"}}>
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
</ul>
</Box>);
}
return (
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
<Box sx={{width: "100%", overflow: "auto"}}>
<table cellSpacing="0" width="100%">
<thead>
<tr style={{backgroundColor: "#d3d3d3"}}>
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
<td></td>
{fileDescription.headerLetters.map((letter) => <td key={letter} style={{textAlign: "center"}}>{letter}</td>)}
{fileDescription.headerLetters.map((letter, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>
if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip>
}
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{dupeWarning}{letter}</Box>
}
</>
</td>);
})}
</tr>
</thead>
<tbody>
<tr>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
{fileDescription.headerValues.map((value) => <td key={value} style={{backgroundColor: fileDescription.hasHeaderRow ? "#ebebeb" : ""}}>{value}</td>)}
{fileDescription.headerValues.map((value, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
)}
</tr>
{rows.map((i) => (
<tr key={i}>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{fileDescription.bodyValuesPreview[j][i]}</td>)}
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>

View File

@ -266,76 +266,6 @@ function ValidationReview({
</List>
);
/***************************************************************************
**
***************************************************************************/
function previewRecordUsingTableLayout(record: QRecord)
{
if (!previewTableMetaData)
{
return (<Box>Loading...</Box>);
}
const renderedSections: JSX.Element[] = [];
const tableSections = TableUtils.getSectionsForRecordSidebar(previewTableMetaData);
const previewRecord = previewRecords[previewRecordIndex];
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
if (section.isHidden)
{
continue;
}
if (section.fieldNames)
{
renderedSections.push(<Box mb="1rem">
<Box><h4>{section.label}</h4></Box>
<Box ml="1rem">
{renderSectionOfFields(section.name, section.fieldNames, previewTableMetaData, false, previewRecord, undefined, {label: {fontWeight: "500"}})}
</Box>
</Box>);
}
else if (section.widgetName)
{
const widget = qInstance.widgets.get(section.widgetName);
if (widget)
{
let data: ChildRecordListData = null;
if (associationPreviewsByWidgetName[section.widgetName])
{
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
const associationRecords = previewRecord.associatedRecords.get(associationPreview.associationName) ?? [];
data = {
canAddChildRecord: false,
childTableMetaData: childTableMetaData[associationPreview.tableName],
defaultValuesForNewChildRecords: {},
disabledFieldsForNewChildRecords: {},
queryOutput: {records: associationRecords},
totalRows: associationRecords.length,
tablePath: "",
title: "",
viewAllLink: "",
};
renderedSections.push(<Box mb="1rem">
{
data && <Box>
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
<Box pl="1rem">
<RecordGridWidget data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
</Box>
</Box>
}
</Box>);
}
}
}
}
return renderedSections;
}
const recordPreviewWidget = step.recordListFields && (
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
@ -370,11 +300,11 @@ function ValidationReview({
{
processValues.validationSummary ? (
<>
It appears as though this process does not contain any valid records.
It appears as though this process does not contain any valid records.
</>
) : (
<>
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
</>
)
}
@ -405,7 +335,15 @@ function ValidationReview({
))
}
{
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && previewRecordUsingTableLayout(previewRecords[previewRecordIndex])
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
<PreviewRecordUsingTableLayout
index={previewRecordIndex}
record={previewRecords[previewRecordIndex]}
tableMetaData={previewTableMetaData}
qInstance={qInstance}
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
childTableMetaData={childTableMetaData}
/>
}
</Box>
</Box>
@ -441,4 +379,84 @@ function ValidationReview({
);
}
interface PreviewRecordUsingTableLayoutProps
{
index: number
record: QRecord,
tableMetaData: QTableMetaData,
qInstance: QInstance,
associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview },
childTableMetaData: { [name: string]: QTableMetaData },
}
function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associationPreviewsByWidgetName, childTableMetaData, index}: PreviewRecordUsingTableLayoutProps): JSX.Element
{
if (!tableMetaData)
{
return (<i>Loading...</i>);
}
const renderedSections: JSX.Element[] = [];
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData);
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
if (section.isHidden)
{
continue;
}
if (section.fieldNames)
{
renderedSections.push(<Box mb="1rem">
<Box><h4>{section.label}</h4></Box>
<Box ml="1rem">
{renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})}
</Box>
</Box>);
}
else if (section.widgetName)
{
const widget = qInstance.widgets.get(section.widgetName);
if (widget)
{
let data: ChildRecordListData = null;
if (associationPreviewsByWidgetName[section.widgetName])
{
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? [];
data = {
canAddChildRecord: false,
childTableMetaData: childTableMetaData[associationPreview.tableName],
defaultValuesForNewChildRecords: {},
disabledFieldsForNewChildRecords: {},
queryOutput: {records: associationRecords},
totalRows: associationRecords.length,
tablePath: "",
title: "",
viewAllLink: "",
};
renderedSections.push(<Box mb="1rem">
{
data && <Box>
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
<Box pl="1rem">
<RecordGridWidget key={index} data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
</Box>
</Box>
}
</Box>);
}
}
}
}
return <>{renderedSections}</>;
}
export default ValidationReview;

View File

@ -83,6 +83,8 @@ interface BasicAndAdvancedQueryControlsProps
mode: string;
setMode: (mode: string) => void;
omitExposedJoins?: string[];
}
let debounceTimeout: string | number | NodeJS.Timeout;
@ -627,6 +629,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
handleSelectedField={handleSetSort}
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
handleAdornmentClick={handleSetSortArrowClick}
omitExposedJoins={props.omitExposedJoins}
/>);
const filterBuilderMouseEvents =
@ -721,6 +724,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
buttonChildren={"Add Filter"}
isModeSelectOne={true}
handleSelectedField={handleFieldListMenuSelection}
omitExposedJoins={props.omitExposedJoins}
/>
}
</>

View File

@ -43,6 +43,7 @@ declare module "@mui/x-data-grid"
metaData: QInstance;
queryFilter: QQueryFilter;
updateFilter: (newFilter: QQueryFilter) => void;
omitExposedJoins?: string[]
}
}
@ -181,6 +182,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
allowVariables={props.allowVariables}
queryScreenUsage={props.queryScreenUsage}
omitExposedJoins={props.omitExposedJoins}
/>
{/*JSON.stringify(criteria)*/}
</Box>

View File

@ -26,9 +26,9 @@ import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import {GridRowsProp} from "@mui/x-data-grid-pro";
import React from "react";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
interface CustomPaginationProps
{
@ -56,7 +56,7 @@ export default function CustomPaginationComponent({tableMetaData, rows, totalRec
The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records
that match your query, because you have included fields from other tables which may have
more than one record associated with each {tableMetaData?.label}.
</>
</>;
let distinctPart = isJoinMany ? (<Box display="inline" component="span" textAlign="right">
&nbsp;({ValueUtils.safeToLocaleString(distinctRecords)} distinct<CustomWidthTooltip title={tooltipHTML}>
<IconButton sx={{p: 0, pl: 0.25, mb: 0.25}}><Icon fontSize="small" sx={{fontSize: "1.125rem !important", color: "#9f9f9f"}}>info_outlined</Icon></IconButton>
@ -66,13 +66,23 @@ export default function CustomPaginationComponent({tableMetaData, rows, totalRec
if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{
if (loading)
{
return "Counting...";
}
if (!rows || rows.length == 0)
{
return "No rows";
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, //
// we'll do this... not quite good enough, but better than the original //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (rows.length > 0 && rows.length < to - from)
{
to = from + rows.length;
to = from + (rows.length - 1);
}
return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`);
}
@ -102,14 +112,55 @@ export default function CustomPaginationComponent({tableMetaData, rows, totalRec
}
};
///////////////////////////////////////////////////////////////////////////////
// the `count` param that we pass to <TablePagination> below is very //
// important - it drives which of the < and > (prev & next) buttons are //
// enabled - and, it's a little tricky for tables where we don't do a count. //
///////////////////////////////////////////////////////////////////////////////
let countForTablePagination: number;
if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{
////////////////////////////////////////////
// handle tables where count is disabled. //
////////////////////////////////////////////
if(!rows || rows.length == 0)
{
/////////////////////////////////////////////
// if we have no rows, assume a count of 0 //
/////////////////////////////////////////////
countForTablePagination = 0;
}
if(rows.length < rowsPerPage)
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// if the # of rows we have is less than the rowsPerPage, assume we're at the end of the query //
// so, setting count to pageNo*rowsPer + rows.length - leaves prev. enabled, but disables next. //
//////////////////////////////////////////////////////////////////////////////////////////////////
countForTablePagination = (pageNumber * rowsPerPage) + rows.length;
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////
// else, we don't know how many more pages there could be - so, just assume it's at least 1 more //
///////////////////////////////////////////////////////////////////////////////////////////////////
countForTablePagination = ((pageNumber + 1) * rowsPerPage) + 1;
}
}
else
{
////////////////////////////////////////////////////////////////////////////////
// cases where count is enabled - they work much more like we'd expect: //
// if we don't know totalRecords (probably same as loading?) - use a -1, //
// which lets us see < and > both active; else, use totalRecords when known. //
////////////////////////////////////////////////////////////////////////////////
countForTablePagination = totalRecords === null || totalRecords === undefined ? -1 : totalRecords;
}
return (
<TablePagination
component="div"
sx={{minWidth: "450px"}}
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
// so pass a sentinel value of -1...
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
sx={{minWidth: "450px", "& .MuiTablePagination-displayedRows": {minWidth: "110px"}}}
count={countForTablePagination}
page={pageNumber}
rowsPerPageOptions={[10, 25, 50, 100, 250]}
rowsPerPage={rowsPerPage}

View File

@ -19,6 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QExposedJoin} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QExposedJoin";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Box from "@mui/material/Box";
@ -31,28 +32,26 @@ import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
import React, {useMemo, useState} from "react";
interface FieldListMenuProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
tableMetaData: QTableMetaData;
showTableHeaderEvenIfNoExposedJoins: boolean;
fieldNamesToHide?: string[];
buttonProps: any;
buttonChildren: JSX.Element | string;
isModeSelectOne?: boolean;
handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void;
isModeToggle?: boolean;
toggleStates?: {[fieldName: string]: boolean};
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void;
fieldEndAdornment?: JSX.Element
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void;
idPrefix: string,
heading?: string,
placeholder?: string,
tableMetaData: QTableMetaData,
showTableHeaderEvenIfNoExposedJoins: boolean,
fieldNamesToHide?: string[],
buttonProps: any,
buttonChildren: JSX.Element | string,
isModeSelectOne?: boolean,
handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void,
isModeToggle?: boolean,
toggleStates?: { [fieldName: string]: boolean },
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void,
fieldEndAdornment?: JSX.Element,
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void,
omitExposedJoins?: string[]
}
FieldListMenu.defaultProps = {
@ -71,38 +70,52 @@ interface TableWithFields
** Component to render a list of fields from a table (and its join tables)
** which can be interacted with...
*******************************************************************************/
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick, omitExposedJoins}: FieldListMenuProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean});
const [collapsedTables, setCollapsedTables] = useState({} as { [tableName: string]: boolean });
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0)
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0);
const availableExposedJoins = useMemo(() =>
{
const rs: QExposedJoin[] = []
for(let exposedJoin of tableMetaData.exposedJoins ?? [])
{
if(omitExposedJoins?.indexOf(exposedJoin.joinTable.name) > -1)
{
continue;
}
rs.push(exposedJoin);
}
return (rs);
}, [tableMetaData, omitExposedJoins]);
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
if (isModeSelectOne)
{
if(!handleSelectedField)
if (!handleSelectedField)
{
throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided.");
throw ("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided.");
}
}
if(isModeToggle)
if (isModeToggle)
{
if(!toggleStates)
if (!toggleStates)
{
throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided.");
throw ("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleField)
if (!handleToggleField)
{
throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided.");
throw ("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided.");
}
}
@ -113,16 +126,16 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
{
collapsedTables[tableMetaData.name] = false;
if (tableMetaData.exposedJoins?.length > 0)
if (availableExposedJoins?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
for (let i = 0; i < availableExposedJoins?.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
const joinTable = availableExposedJoins[i].joinTable;
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
collapsedTables[joinTable.name] = false;
@ -150,16 +163,16 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
table.fields.forEach(field =>
{
let fullFieldName = field.name;
if(table.name != tableMetaData.name)
if (table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
if (fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
{
return;
}
fields.push(field)
fields.push(field);
});
fields.sort((a, b) => a.label.localeCompare(b.label));
return (fields);
@ -181,7 +194,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
/*******************************************************************************
**
*******************************************************************************/
function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData}
function getShownFieldAndTableByIndex(targetIndex: number): { field: QFieldMetaData, table: QTableMetaData }
{
let index = -1;
for (let i = 0; i < fieldsByTableToShow.length; i++)
@ -191,9 +204,9 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
{
index++;
if(index == targetIndex)
if (index == targetIndex)
{
return {field: tableWithField.fields[j], table: tableWithField.table}
return {field: tableWithField.fields[j], table: tableWithField.table};
}
}
}
@ -210,7 +223,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if(isModeSelectOne && event.key == "Enter" && focusedIndex != null)
if (isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
@ -249,13 +262,13 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
/////////////////
// a down move //
/////////////////
if(startIndex == null)
if (startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if(goalIndex > maxFieldIndex - 1)
if (goalIndex > maxFieldIndex - 1)
{
goalIndex = maxFieldIndex - 1;
}
@ -268,7 +281,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
// an up move //
////////////////
let goalIndex = startIndex + offset;
if(goalIndex < 0)
if (goalIndex < 0)
{
goalIndex = 0;
}
@ -335,7 +348,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
if (event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
@ -343,7 +356,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if(now < timeOfLastArrow + 300)
if (now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
@ -480,7 +493,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
for (let i = 0; i < fieldsList.length; i++)
{
const field = fieldsList[i];
if(doesFieldMatchSearchText(field))
if (doesFieldMatchSearchText(field))
{
handleToggleField(field, table, event.target.checked);
}
@ -491,18 +504,18 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
/////////////////////////////////////////////////////////
// compute the table-level toggle state & count values //
/////////////////////////////////////////////////////////
const tableToggleStates: {[tableName: string]: boolean} = {};
const tableToggleCounts: {[tableName: string]: number} = {};
const tableToggleStates: { [tableName: string]: boolean } = {};
const tableToggleCounts: { [tableName: string]: number } = {};
if(isModeToggle)
if (isModeToggle)
{
const {allOn, count} = getTableToggleState(tableMetaData, true);
tableToggleStates[tableMetaData.name] = allOn;
tableToggleCounts[tableMetaData.name] = count;
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
for (let i = 0; i < availableExposedJoins?.length; i++)
{
const join = tableMetaData.exposedJoins[i];
const join = availableExposedJoins[i];
const {allOn, count} = getTableToggleState(join.joinTable, false);
tableToggleStates[join.joinTable.name] = allOn;
tableToggleCounts[join.joinTable.name] = count;
@ -513,7 +526,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
/*******************************************************************************
**
*******************************************************************************/
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number}
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): { allOn: boolean, count: number }
{
const fieldsList = [...table.fields.values()];
let allOn = true;
@ -522,7 +535,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
{
const field = fieldsList[i];
const name = isMainTable ? field.name : `${table.name}.${field.name}`;
if(!toggleStates[name])
if (!toggleStates[name])
{
allOn = false;
}
@ -541,7 +554,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
*******************************************************************************/
function toggleCollapsedTable(tableName: string)
{
collapsedTables[tableName] = !collapsedTables[tableName]
collapsedTables[tableName] = !collapsedTables[tableName];
setCollapsedTables(Object.assign({}, collapsedTables));
}
@ -559,7 +572,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem": "0.5rem";
let listItemPadding = isModeToggle ? "0.125rem" : "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
@ -607,12 +620,12 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
{
let headerContents = null;
const headerTable = tableWithFields.table || tableMetaData;
if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
if (tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
{
headerContents = (<b>{headerTable.label} Fields</b>);
}
if(isModeToggle)
if (isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
@ -622,10 +635,10 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
checked={tableToggleStates[headerTable.name]}
onChange={(event) => handleTableToggle(event, headerTable)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />)
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />);
}
if(isModeToggle)
if (isModeToggle)
{
headerContents = (
<>
@ -638,11 +651,11 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
</IconButton>
{headerContents}
</>
)
);
}
let marginLeft = "unset";
if(isModeToggle)
if (isModeToggle)
{
marginLeft = "-1rem";
}
@ -652,14 +665,14 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
return (
<React.Fragment key={tableWithFields.table?.name ?? "theTable"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex+1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex + 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
tableWithFields.fields.map((field) =>
{
index++;
const key = `${tableWithFields.table?.name}-${field.name}`
const key = `${tableWithFields.table?.name}-${field.name}`;
if(collapsedTables[headerTable.name])
if (collapsedTables[headerTable.name])
{
return (<React.Fragment key={key} />);
}
@ -677,13 +690,13 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
{
closeMenu();
handleSelectedField(field, tableWithFields.table ?? tableMetaData);
}
};
}
let label: JSX.Element | string = field.label;
const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name;
if(fieldEndAdornment)
if (fieldEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}

View File

@ -19,6 +19,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
@ -28,20 +32,26 @@ import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {GridFilterItem} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import ChipTextField from "qqq/components/forms/ChipTextField";
import HelpContent from "qqq/components/misc/HelpContent";
import {LoadingState} from "qqq/models/LoadingState";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
interface Props
{
type: string;
onSave: (newValues: any[]) => void;
table?: QTableMetaData;
field?: QFieldMetaData;
}
FilterCriteriaPaster.defaultProps = {};
const qController = Client.getInstance();
function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
{
enum Delimiter
{
@ -68,6 +78,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
mainCardStyles.width = "60%";
mainCardStyles.minWidth = "500px";
///////////////////////////////////////////////////////////////////////////////////////////
// add a LoadingState object, in case the initial loads (of meta data and view) are slow //
///////////////////////////////////////////////////////////////////////////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [pageLoadingState, _] = useState(new LoadingState(forceUpdate));
//x const [gridFilterItem, setGridFilterItem] = useState(props.item);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState("");
@ -75,8 +91,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
const [chipData, setChipData] = useState(undefined);
const [uniqueCount, setUniqueCount] = useState(undefined);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [detectedText, setDetectedText] = useState("");
const [errorText, setErrorText] = useState("");
const [saveDisabled, setSaveDisabled] = useState(true);
const [metaData, setMetaData] = useState(null as QInstance);
//////////////////////////////////////////////////////////////
// handler for when paste icon is clicked in 'any' operator //
@ -92,6 +113,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
setDelimiter("");
setDelimiterCharacter("");
setChipData([]);
setChipValidity([]);
setInputText("");
setDetectedText("");
setCustomDelimiterValue("");
@ -106,18 +128,43 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const handleSaveClicked = () =>
{
////////////////////////////////////////
// if numeric remove any non-numerics //
////////////////////////////////////////
///////////////////////////////////////////////////////////////
// if numeric remove any non-numerics, or invalid pvs values //
///////////////////////////////////////////////////////////////
let saveData = [];
let usedLabels = new Map<any, boolean>();
for (let i = 0; i < chipData.length; i++)
{
if (type !== "number" || !Number.isNaN(Number(chipData[i])))
if (chipValidity[i] === true)
{
saveData.push(chipData[i]);
if (type === "pvs")
{
/////////////////////////////////////////////
// if already used this PVS label, skip it //
/////////////////////////////////////////////
if (usedLabels.get(chipData[i]) != null)
{
continue;
}
saveData.push(new QPossibleValue({id: chipPVSIds[i], label: chipData[i]}));
usedLabels.set(chipData[i], true);
}
else
{
saveData.push(chipData[i]);
}
}
}
//////////////////////////////////////////
// for pvs, sort by label before saving //
//////////////////////////////////////////
if (type === "pvs")
{
saveData.sort((a: QPossibleValue, b: QPossibleValue) => b.label.localeCompare(a.label));
}
onSave(saveData);
clearData();
@ -214,6 +261,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
useEffect(() =>
{
(async () =>
{
const metaData = await qController.loadMetaData();
setMetaData(metaData);
})();
let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter;
@ -246,10 +299,16 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
let parts = inputText.split(regex);
let chipData = [] as string[];
/////////////////////////////////////////////////////////////////
// use a map to keep track of the counts for each unique value //
/////////////////////////////////////////////////////////////////
const uniqueValuesMap: { [key: string]: number } = {};
///////////////////////////////////////////////////////
// if delimiter is empty string, dont split anything //
///////////////////////////////////////////////////////
setErrorText("");
let invalidCount = 0;
if (currentDelimiterCharacter !== "")
{
for (let i = 0; i < parts.length; i++)
@ -259,152 +318,207 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
{
chipData.push(part);
///////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip //
///////////////////////////////////////////////////////////
if (type === "number" && Number.isNaN(Number(part)))
////////////////////////////////////////////////////////////////
// if numeric or pvs, check validity and add to invalid count //
////////////////////////////////////////////////////////////////
if (chipValidity[i] != null && chipValidity[i] !== true)
{
setErrorText("Some values are not numbers");
if ((type === "number" && Number.isNaN(Number(part))) || type === "pvs")
{
invalidCount++;
}
}
else
{
let count = uniqueValuesMap[part] == null ? 0 : uniqueValuesMap[part];
uniqueValuesMap[part] = count + 1;
}
}
}
}
if (invalidCount > 0)
{
if (type === "number")
{
let suffix = invalidCount === 1 ? " value is not a number" : " values are not numbers";
setErrorText(invalidCount + suffix + " and will not be added to the filter");
}
else if (type === "pvs")
{
let suffix = invalidCount === 1 ? " value was" : " values were";
setErrorText(invalidCount + suffix + " not found and will not be added to the filter");
}
}
setUniqueCount(Object.keys(uniqueValuesMap).length);
setChipData(chipData);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText, chipValidity]);
const slotName = type === "pvs" ? "bulkAddFilterValuesPossibleValueSource" : "bulkAddFilterValues";
const helpRoles = ["QUERY_SCREEN"];
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(slotName)} roles={helpRoles} heading={null} helpContentKey={`instanceLevel:true;slot:${slotName}`} />;
return (
<Box>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
<Icon className="criteriaPasterButton" onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left.
Review the filter values in the box on the right.
If the filter values are not what are expected, try changing the separator using the dropdown below.
</Typography>
<Box>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
{
formattedHelpContent && <Box sx={{display: "flex", lineHeight: "1.7", textTransform: "none"}}>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
{formattedHelpContent}
</Typography>
</Box>
}
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
className="criteriaPasterTextArea"
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={(isMakingRequest: boolean, chipValidity: boolean[], chipPVSIds: any[]) =>
{
setErrorText("");
if (isMakingRequest)
{
pageLoadingState.setLoading();
}
else
{
pageLoadingState.setNotLoading();
}
setSaveDisabled(isMakingRequest);
setChipPVSIds(chipPVSIds);
setChipValidity(chipValidity);
}}
table={table}
field={field}
chipData={chipData}
chipValidity={chipValidity}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
{
}}
chipData={chipData}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
{delimiter === Delimiter.CUSTOM.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
{delimiter === Delimiter.CUSTOM.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={4} lg={4}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
{
pageLoadingState.isLoadingSlow() && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="warning">warning</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">Loading...</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={2} lg={2}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} {uniqueCount && `(${uniqueCount} unique)`}</Typography>
)
}
</Grid>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
)
}
</Grid>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
</Grid>
</Box>
</Card>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Values" disabled={saveDisabled} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Box>
</Modal>

View File

@ -33,6 +33,7 @@ import MenuItem from "@mui/material/MenuItem";
import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {omit} from "lodash";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
@ -109,6 +110,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
{
case QFieldType.DECIMAL:
case QFieldType.INTEGER:
case QFieldType.LONG:
operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE});
@ -189,17 +191,18 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
interface FilterCriteriaRowProps
{
id: number;
index: number;
tableMetaData: QTableMetaData;
metaData: QInstance;
criteria: QFilterCriteria;
booleanOperator: "AND" | "OR" | null;
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void;
removeCriteria: () => void;
updateBooleanOperator: (newValue: string) => void;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
id: number,
index: number,
tableMetaData: QTableMetaData,
metaData: QInstance,
criteria: QFilterCriteria,
booleanOperator: "AND" | "OR" | null,
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void,
removeCriteria: () => void,
updateBooleanOperator: (newValue: string) => void,
queryScreenUsage?: QueryScreenUsage,
allowVariables?: boolean,
omitExposedJoins?: string[]
}
FilterCriteriaRow.defaultProps =
@ -268,7 +271,7 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
return {criteriaIsValid, criteriaStatusTooltip};
}
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator, queryScreenUsage, allowVariables}: FilterCriteriaRowProps): JSX.Element
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator, queryScreenUsage, allowVariables, omitExposedJoins}: FilterCriteriaRowProps): JSX.Element
{
// console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption);
@ -487,7 +490,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange}
autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
omitExposedJoins={omitExposedJoins} autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
/>
</Box>
<Box display="inline-block" width={200} className="operatorColumn">

View File

@ -398,20 +398,25 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values;
}
}
return <Box>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
return <Box display="flex" alignItems="flex-end" className="multiValue">
<Box width={"100%"}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id + "-" + criteria.values.length}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>
<Box>
<FilterCriteriaPaster table={table} field={field} type="pvs" onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
</Box>
</Box>;
}

View File

@ -29,9 +29,9 @@ import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
interface QueryScreenActionMenuProps
{
@ -44,40 +44,35 @@ interface QueryScreenActionMenuProps
processClicked: (process: QProcessMetaData) => void;
}
QueryScreenActionMenu.defaultProps = {
};
QueryScreenActionMenu.defaultProps = {};
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
{
const [anchorElement, setAnchorElement] = useState(null)
const [anchorElement, setAnchorElement] = useState(null);
const navigate = useNavigate();
const openActionsMenu = (event: any) =>
{
setAnchorElement(event.currentTarget);
}
};
const closeActionsMenu = () =>
{
setAnchorElement(null);
}
const pushDividerIfNeeded = (menuItems: JSX.Element[]) =>
{
if (menuItems.length > 0)
{
menuItems.push(<Divider key="divider" />);
}
};
const runSomething = (handler: () => void) =>
{
closeActionsMenu();
handler();
}
};
const menuItems: JSX.Element[] = [];
//////////////////////////////////////////////////////
// start with bulk actions, if user has permissions //
//////////////////////////////////////////////////////
if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission)
{
menuItems.push(<MenuItem key="bulkLoad" onClick={() => runSomething(bulkLoadClicked)}><ListItemIcon><Icon>library_add</Icon></ListItemIcon>Bulk Load</MenuItem>);
@ -91,19 +86,7 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
menuItems.push(<MenuItem key="bulkDelete" onClick={() => runSomething(bulkDeleteClicked)}><ListItemIcon><Icon>delete</Icon></ListItemIcon>Bulk Delete</MenuItem>);
}
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
if (runRecordScriptProcess)
{
const process = runRecordScriptProcess;
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
}
menuItems.push(<MenuItem key="developerMode" onClick={() => navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}><ListItemIcon><Icon>code</Icon></ListItemIcon>Developer Mode</MenuItem>);
if (tableProcesses && tableProcesses.length)
{
pushDividerIfNeeded(menuItems);
}
menuItems.push(<Divider key="divider1" />);
tableProcesses.sort((a, b) => a.label.localeCompare(b.label));
tableProcesses.map((process) =>
@ -111,11 +94,62 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
});
menuItems.push(<Divider key="divider2" />);
////////////////////////////////////////////
// add processes that apply to all tables //
////////////////////////////////////////////
const materialDashboardInstanceMetaData = metaData.supplementalInstanceMetaData?.get("materialDashboard");
if (materialDashboardInstanceMetaData)
{
const processNamesToAddToAllQueryAndViewScreens = materialDashboardInstanceMetaData.processNamesToAddToAllQueryAndViewScreens;
if (processNamesToAddToAllQueryAndViewScreens)
{
for (let processName of processNamesToAddToAllQueryAndViewScreens)
{
const process = metaData?.processes.get(processName);
if (process)
{
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
}
}
}
}
else
{
//////////////////////////////////////
// deprecated in favor of the above //
//////////////////////////////////////
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
if (runRecordScriptProcess)
{
const process = runRecordScriptProcess;
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
}
}
////////////////////////////////////////
// todo - any conditions around this? //
////////////////////////////////////////
menuItems.push(<MenuItem key="developerMode" onClick={() => navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}><ListItemIcon><Icon>code</Icon></ListItemIcon>Developer Mode</MenuItem>);
if (menuItems.length === 0)
{
menuItems.push(<MenuItem key="notAvaialableNow" disabled><ListItemIcon><Icon>block</Icon></ListItemIcon><i>No actions available</i></MenuItem>);
}
////////////////////////////////////////////////////////////////////////////////
// remove any duplicated dividers, and any dividers in the first or last slot //
////////////////////////////////////////////////////////////////////////////////
for (let i = 0; i < menuItems.length; i++)
{
if (menuItems[i].type == Divider && (i == 0 || (i > 0 && menuItems[i - 1].type == Divider) || i == menuItems.length - 1))
{
menuItems.splice(i, 1);
i--;
}
}
return (
<>
<QActionsMenuButton isOpen={anchorElement} onClickHandler={openActionsMenu} />
@ -130,5 +164,5 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
{menuItems}
</Menu>
</>
)
);
}

View File

@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -118,7 +118,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
** autocomplete), given an array of options, the query's active criteria in this
** field, and the default operator to use for this field
*******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
{
if (criteria)
{
@ -135,6 +135,23 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
return (filteredOptions[0]);
}
if (return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
{
console.log("Operator options: " + JSON.stringify(operatorOptions));
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch (e)
{
console.log(`Error in debug output: ${e}`);
}
return operatorOptions[0];
}
return (null);
};
@ -157,7 +174,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
@ -169,6 +186,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
//////////////////////////////////////////////////////////////////////////////
// was not seeing criteria changes take place until watching it stringified //
//////////////////////////////////////////////////////////////////////////////
setCriteria(criteria);
}, [JSON.stringify(criteria)]);
/*******************************************************************************
**

View File

@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
<Card sx={{width: "100%", height: "100%"}}>
<Typography variant="h6" p={2} pb={1}>{heading}</Typography>
<Box className="devDocumentation" height="100%">
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
<AceEditor
mode={mode}
theme="github"
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
width="100%"
showPrintMargin={false}
height="100%"
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
/>
</Typography>
</Box>

View File

@ -39,6 +39,7 @@ import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineCha
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import CustomComponentWidget from "qqq/components/widgets/misc/CustomComponentWidget";
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
import DividerWidget from "qqq/components/widgets/misc/Divider";
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
@ -313,6 +314,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
forceUpdate();
actionCallback(widgetData[widgetIndex]);
};
@ -368,7 +370,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any)
function submitEditChildForm(values: any, tableName: string)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
@ -718,6 +720,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
parentRecord={record}
/>
)
@ -779,8 +782,12 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
{
widgetMetaData.type === "filterAndColumnsSetup" && (
widgetData && widgetData[i] &&
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
{
if(actionCallback)
{
actionCallback(values)
}
}} />
)
}
@ -798,6 +805,14 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
<DynamicFormWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={convertQRecordValuesFromMapToObject(record)} />
)
}
{
widgetMetaData.type === "customComponent" && (
widgetData && widgetData[i] &&
<Widget widgetMetaData={widgetMetaData}>
<CustomComponentWidget widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} />
</Widget>
)
}
</Box>
);
};

View File

@ -728,7 +728,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
{
needLabelBox &&
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"}>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"} className="widgetLabelBox">
<Box display="flex" flexDirection="column">
<Box display="flex" alignItems="baseline">
{

View File

@ -0,0 +1,69 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import useDynamicComponents from "qqq/utils/qqq/useDynamicComponents";
import {useEffect, useState} from "react";
interface CustomComponentWidgetProps
{
widgetMetaData: QWidgetMetaData;
widgetData: any;
record: QRecord;
}
CustomComponentWidget.defaultProps = {
};
/*******************************************************************************
** Component to display a custom component - one dynamically loaded.
*******************************************************************************/
export default function CustomComponentWidget({widgetMetaData, widgetData, record}: CustomComponentWidgetProps): JSX.Element
{
const [componentName, setComponentName] = useState(widgetMetaData.defaultValues.get("componentName"));
const [componentSourceUrl, setComponentSourceUrl] = useState(widgetMetaData.defaultValues.get("componentSourceUrl"));
const {loadComponent, hasComponentLoaded, renderComponent} = useDynamicComponents();
useEffect(() =>
{
loadComponent(componentName, componentSourceUrl);
}, []);
const props: any =
{
widgetMetaData: widgetMetaData,
widgetData: widgetData,
record: record,
}
return (<Box sx={widgetMetaData.defaultValues?.get("sx")}>
{hasComponentLoaded(componentName) ? renderComponent(componentName, props) : <Skeleton width="100%" height="100%" />}
</Box>);
}

View File

@ -20,6 +20,7 @@
*/
import {ApiVersion} from "@kingsrook/qqq-frontend-core/lib/controllers/QControllerV1";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
@ -42,15 +43,17 @@ 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";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {useContext, useEffect, useMemo, useRef, useState} from "react";
interface FilterAndColumnsSetupWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
widgetData: any;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
isEditable: boolean,
widgetMetaData: QWidgetMetaData,
widgetData: any,
recordValues: { [name: string]: any },
onSaveCallback?: (values: { [name: string]: any }) => void,
label?: string
}
FilterAndColumnsSetupWidget.defaultProps = {
@ -79,18 +82,31 @@ unborderedButtonSX.opacity = "0.7";
const qController = Client.getInstance();
const qControllerV1 = Client.getInstanceV1();
/*******************************************************************************
** Component for editing the main setup of a report - that is: filter & columns
*******************************************************************************/
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
export default function FilterAndColumnsSetupWidget({isEditable: isEditableProp, widgetMetaData, widgetData, recordValues, onSaveCallback, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
{
const [modalOpen, setModalOpen] = useState(false);
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview);
const [hideColumns] = useState(widgetData?.hideColumns);
const [hidePreview] = useState(widgetData?.hidePreview);
const [hideSortBy] = useState(widgetData?.hideSortBy);
const [isEditable] = useState(widgetData?.overrideIsEditable ?? isEditableProp);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [isApiVersioned] = useState(widgetData?.isApiVersioned);
const [apiVersion, setApiVersion] = useState(null as ApiVersion | null);
const [filterFieldName] = useState(widgetData?.filterFieldName ?? "queryFilterJson");
const [columnsFieldName] = useState(widgetData?.columnsFieldName ?? "columnsJson");
const [alertContent, setAlertContent] = useState(null as string);
const [warning, setWarning] = useState(null as string);
const [widgetFailureAlertContent, setWidgetFailureAlertContent] = useState(null as string);
const omitExposedJoins: string[] = widgetData?.omitExposedJoins ?? [];
//////////////////////////////////////////////////////////////////////////////////////////////////
// we'll actually keep 2 copies of the query filter around here - //
@ -108,7 +124,9 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/////////////////////////////
let columns: QQueryColumns = null;
let usingDefaultEmptyFilter = false;
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
const rawFilterValueFromRecord = recordValues[filterFieldName];
let queryFilter = rawFilterValueFromRecord &&
((typeof rawFilterValueFromRecord == "string" ? JSON.parse(rawFilterValueFromRecord) : rawFilterValueFromRecord) as QQueryFilter);
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
if (!queryFilter)
{
@ -142,9 +160,9 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
});
}
if (recordValues["columnsJson"])
if (recordValues[columnsFieldName])
{
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
columns = QQueryColumns.buildFromJSON(recordValues[columnsFieldName]);
}
//////////////////////////////////////////////////////////////////////
@ -161,16 +179,73 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
tableName = recordValues["tableName"];
}
let version: ApiVersion | null = null;
if (isApiVersioned)
{
let apiName = widgetData?.apiName;
let apiPath = widgetData?.apiPath;
let apiVersion = widgetData?.apiVersion;
if (!apiName && recordValues["apiName"])
{
apiName = recordValues["apiName"];
}
if (!apiPath && recordValues["apiPath"])
{
apiPath = recordValues["apiPath"];
}
if (!apiVersion && recordValues["apiVersion"])
{
apiVersion = recordValues["apiVersion"];
}
if (!apiName || !apiPath || !apiVersion)
{
console.log("API Name/Path/Version not set, but widget isApiVersioned, so cannot load table meta data...");
return;
}
version = {name: apiName, path: apiPath, version: apiVersion};
setApiVersion(version);
}
if (tableName)
{
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
try
{
const tableMetaData = await qControllerV1.loadTableMetaData(tableName, version);
setTableMetaData(tableMetaData);
const queryFilterForFrontend = Object.assign({}, queryFilter);
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend);
setFrontendQueryFilter(queryFilterForFrontend);
const queryFilterForFrontend = Object.assign({}, queryFilter);
let warnings: string[] = [];
for (let i = 0; i < queryFilterForFrontend?.criteria?.length; i++)
{
const criteria = queryFilter.criteria[i];
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
if(!field)
{
warnings.push("Removing non-existing filter field: " + criteria.fieldName);
queryFilterForFrontend.criteria.splice(i, 1);
i--;
}
}
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend);
setFrontendQueryFilter(queryFilterForFrontend);
setWarning(warnings.join("; "));
}
catch (e)
{
console.log(e);
//@ts-ignore e.message
setWidgetFailureAlertContent("Error preparing filter widget: " + (e.message ?? "Details not available."));
}
})();
}
}, [JSON.stringify(recordValues)]);
@ -199,7 +274,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
return;
}
if (recordValues["tableName"])
if (widgetData?.tableName || recordValues["tableName"])
{
setAlertContent(null);
setModalOpen(true);
@ -230,7 +305,10 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
setFrontendQueryFilter(view.queryFilter);
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)});
const rs: { [key: string]: any } = {};
rs[filterFieldName] = JSON.stringify(filter);
rs[columnsFieldName] = JSON.stringify(view.queryColumns);
onSaveCallback(rs);
closeEditor();
}
@ -328,7 +406,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/////////////////////////////////////////////////
// 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 selectTableFirstTooltipTitle = tableMetaData ? null : `You must select a table${isApiVersioned ? " and API details" : ""} before you can set up your filters${hideColumns ? "" : " and columns"}`;
const labelAdditionalElementsRight: JSX.Element[] = [];
if (isEditable)
{
@ -342,6 +420,12 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
}
}
if (widgetFailureAlertContent)
{
return (<Widget widgetMetaData={widgetMetaData}>
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}}>{widgetFailureAlertContent}</Alert>
</Widget>);
}
return (<Widget widgetMetaData={widgetMetaData} labelAdditionalElementsRight={labelAdditionalElementsRight}>
<React.Fragment>
@ -354,10 +438,13 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
<Collapse in={Boolean(alertContent)}>
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}} onClose={() => setAlertContent(null)}>{alertContent}</Alert>
</Collapse>
<Collapse in={Boolean(warning)}>
<Alert severity="warning" sx={{mt: 1.5, mb: 0.5}} onClose={() => setWarning(null)}>{warning}</Alert>
</Collapse>
<Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center">
<h5>Query Filter</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
<h5>{label ?? widgetData.label ?? widgetMetaData.label ?? "Query Filter"}</h5>
{!hideSortBy && <Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>}
</Box>
{
mayShowQuery() &&
@ -369,7 +456,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={{mb: "0.125rem", ...unborderedButtonSX}} onClick={openEditor}>+ Add Filters</Button></span>
<span><Button disabled={tableMetaData == null} sx={{mb: "0.125rem", ...unborderedButtonSX}} onClick={openEditor}>+ Add Filters</Button></span>
</Tooltip>
}
{
@ -415,6 +502,8 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
isModal={true}
initialQueryFilter={frontendQueryFilter}
initialColumns={columns}
apiVersion={apiVersion}
omitExposedJoins={omitExposedJoins}
/>
</Box>
)}
@ -424,7 +513,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
<div>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{m: "2rem", p: "2rem"}}>
<h3>Edit Filters and Columns</h3>
<h3>Edit Filters {hideColumns ? "" : " and Columns"}</h3>
{
showHelp("modalSubheader") &&
<Box color={colors.gray.main} pb={"0.5rem"}>
@ -440,6 +529,8 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
isModal={true}
initialQueryFilter={usingDefaultEmptyFilter ? null : frontendQueryFilter}
initialColumns={columns}
apiVersion={apiVersion}
omitExposedJoins={omitExposedJoins}
/>
}

View File

@ -40,7 +40,7 @@ import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData
{
title?: string;
queryOutput?: { records: { values: any }[] };
queryOutput?: { records: { values: any, displayValues?: any } [] };
childTableMetaData?: QTableMetaData;
tablePath?: string;
viewAllLink?: string;
@ -48,20 +48,23 @@ export interface ChildRecordListData extends WidgetData
canAddChildRecord?: boolean;
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
omitFieldNames?: string[];
}
interface Props
{
widgetMetaData: QWidgetMetaData;
data: ChildRecordListData;
addNewRecordCallback?: () => void;
disableRowClick: boolean;
allowRecordEdit: boolean;
editRecordCallback?: (rowIndex: number) => void;
allowRecordDelete: boolean;
deleteRecordCallback?: (rowIndex: number) => void;
gridOnly?: boolean;
gridDensity?: GridDensity;
widgetMetaData: QWidgetMetaData,
data: ChildRecordListData,
addNewRecordCallback?: () => void,
disableRowClick: boolean,
allowRecordEdit: boolean,
editRecordCallback?: (rowIndex: number) => void,
allowRecordDelete: boolean,
deleteRecordCallback?: (rowIndex: number) => void,
gridOnly?: boolean,
gridDensity?: GridDensity,
parentRecord?: QRecord
}
RecordGridWidget.defaultProps =
@ -74,7 +77,7 @@ RecordGridWidget.defaultProps =
const qController = Client.getInstance();
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity}: Props): JSX.Element
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity, parentRecord}: Props): JSX.Element
{
const instance = useRef({timer: null});
const [rows, setRows] = useState([]);
@ -97,7 +100,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
for (let i = 0; i < queryOutputRecords.length; i++)
{
if(queryOutputRecords[i] instanceof QRecord)
if (queryOutputRecords[i] instanceof QRecord)
{
records.push(queryOutputRecords[i] as QRecord);
}
@ -109,7 +112,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, true);
const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true);
/////////////////////////////////////////////////////////////////////////////////
// note - tablePath may be null, if the user doesn't have access to the table. //
@ -117,6 +120,19 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath;
const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath, null, "bySection");
if (data.omitFieldNames)
{
for (let i = 0; i < columns.length; i++)
{
const column = columns[i];
if (data.omitFieldNames.indexOf(column.field) > -1)
{
columns.splice(i, 1);
i--;
}
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// capture all-columns to use for the export (before we might splice some away from the on-screen display) //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -252,7 +268,22 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
disabledFields = data.defaultValuesForNewChildRecords;
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {};
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if (data.defaultValuesForNewChildRecordsFromParentFields)
{
for (let childField in data.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField);
}
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
}
@ -357,7 +388,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
/>
);
if(gridOnly)
if (gridOnly)
{
return (grid);
}

View File

@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
<Grid container className="scriptViewer" my={-3} mx={-3} pt={4} width={"calc(100% + 3rem)"}>
<Grid item xs={12}>
<Box>
{
@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel>
<TabPanel index={2} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}>
<Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptTestForm scriptId={scriptId}
scriptType={scriptTypeRecord}
tableName={associatedScriptTableName}
@ -543,7 +543,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel>
<TabPanel index={3} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}>
<Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
</Box>
</TabPanel>

View File

@ -35,7 +35,7 @@ export interface ModalEditFormData
defaultValues?: { [key: string]: string };
disabledFields?: { [key: string]: boolean } | string[];
overrideHeading?: string;
onSubmitCallback?: (values: any) => void;
onSubmitCallback?: (values: any, tableName: String) => void;
initialShowModalValue?: boolean;
}

View File

@ -21,11 +21,12 @@
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import Box from "@mui/material/Box";
import {ReactNode, useEffect, useState} from "react";
import Footer from "qqq/components/horseshoe/Footer";
import NavBar from "qqq/components/horseshoe/NavBar";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import DashboardLayout from "qqq/layouts/DashboardLayout";
import Client from "qqq/utils/qqq/Client";
import {ReactNode, useEffect, useState} from "react";
interface Props
{
@ -80,12 +81,34 @@ function BaseLayout({stickyNavbar, children}: Props): JSX.Element
return () => window.removeEventListener("resize", handleTabsOrientation);
}, [tabsOrientation]);
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_BODY");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", margin: "-20px", marginBottom: "20px", ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return (
<DashboardLayout>
<NavBar />
<Box>{children}</Box>
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
</DashboardLayout>
<>
<DashboardLayout>
{banner()}
<NavBar />
<Box>{children}</Box>
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
</DashboardLayout>
</>
);
}

View File

@ -20,6 +20,7 @@
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
/*******************************************************************************
** Properties attached to a (formik?) form field, to denote how it behaves as
@ -34,5 +35,6 @@ export interface FieldPossibleValueProps
tableName?: string;
processName?: string;
possibleValueSourceName?: string;
possibleValueSourceFilter?: QQueryFilter;
}

View File

@ -21,6 +21,7 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
export type ValueType = "defaultValue" | "column";
@ -42,6 +43,7 @@ export class BulkLoadField
wideLayoutIndexPath: number[] = [];
error: string = null;
warning: string = null;
key: string;
@ -49,7 +51,7 @@ export class BulkLoadField
/***************************************************************************
**
***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [])
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null)
{
this.field = field;
this.tableStructure = tableStructure;
@ -59,6 +61,8 @@ export class BulkLoadField
this.defaultValue = defaultValue;
this.doValueMapping = doValueMapping;
this.wideLayoutIndexPath = wideLayoutIndexPath;
this.error = error;
this.warning = warning;
this.key = new Date().getTime().toString();
}
@ -68,7 +72,7 @@ export class BulkLoadField
***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField
{
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath));
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning));
}
@ -422,17 +426,22 @@ export class BulkLoadMapping
}
else
{
index = 0;
///////////////////////////////////////////////////////////
// count how many copies of this field there are already //
///////////////////////////////////////////////////////////
///////////////////////////////////////////////
// find the max index for this field already //
///////////////////////////////////////////////
let maxIndex = -1;
for (let existingField of [...this.requiredFields, ...this.additionalFields])
{
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
{
index++;
const thisIndex = existingField.wideLayoutIndexPath[0];
if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex)
{
maxIndex = thisIndex;
}
}
}
index = maxIndex + 1;
}
const cloneField = BulkLoadField.clone(bulkLoadField);
@ -455,7 +464,7 @@ export class BulkLoadMapping
const newAdditionalFields: BulkLoadField[] = [];
for (let bulkLoadField of this.additionalFields)
{
if (bulkLoadField.getQualifiedName() != toRemove.getQualifiedName())
if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix())
{
newAdditionalFields.push(bulkLoadField);
}
@ -463,6 +472,171 @@ export class BulkLoadMapping
this.additionalFields = newAdditionalFields;
}
/***************************************************************************
**
***************************************************************************/
public switchLayout(newLayout: string): void
{
const newAdditionalFields: BulkLoadField[] = [];
let anyChanges = false;
if ("WIDE" != newLayout)
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// if going to a layout other than WIDE, make sure there aren't any fields with a wideLayoutIndexPath //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const namesWhereOneWideLayoutIndexHasBeenFound: { [name: string]: boolean } = {};
for (let existingField of this.additionalFields)
{
if (existingField.wideLayoutIndexPath.length > 0)
{
const name = existingField.getQualifiedName();
if (namesWhereOneWideLayoutIndexHasBeenFound[name])
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////
// in this case, we're on like the 2nd or 3rd instance of, say, Line Item: SKU - so - just discard it. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////
anyChanges = true;
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else, this is the 1st instance of, say, Line Item: SKU - so mark that we've found it - and keep this field //
// (that is, put it in the new array), but with no index path //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
namesWhereOneWideLayoutIndexHasBeenFound[name] = true;
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [];
newAdditionalFields.push(newField);
anyChanges = true;
}
}
else
{
//////////////////////////////////////////////////////
// else, non-wide-path fields, just get added as-is //
//////////////////////////////////////////////////////
newAdditionalFields.push(existingField);
}
}
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////
// if going to WIDE layout, then any field from a child table needs a wide-layout-index-path //
///////////////////////////////////////////////////////////////////////////////////////////////
for (let existingField of this.additionalFields)
{
if (existingField.tableStructure.isMain)
{
////////////////////////////////////////////
// fields from main table come over as-is //
////////////////////////////////////////////
newAdditionalFields.push(existingField);
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////
// fields from child tables get a wideLayoutIndexPath (and we're assuming just 1 for each) //
/////////////////////////////////////////////////////////////////////////////////////////////
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [0];
newAdditionalFields.push(newField);
anyChanges = true;
}
}
}
if (anyChanges)
{
this.additionalFields = newAdditionalFields;
}
this.layout = newLayout;
}
/***************************************************************************
**
***************************************************************************/
public getFieldsForColumnIndex(i: number): BulkLoadField[]
{
const rs: BulkLoadField[] = [];
for (let field of [...this.requiredFields, ...this.additionalFields])
{
if (field.valueType == "column" && field.columnIndex == i)
{
rs.push(field);
}
}
return (rs);
}
/***************************************************************************
**
***************************************************************************/
public handleChangeToHasHeaderRow(newValue: any, fileDescription: FileDescription)
{
const newRequiredFields: BulkLoadField[] = [];
let anyChangesToRequiredFields = false;
const newAdditionalFields: BulkLoadField[] = [];
let anyChangesToAdditionalFields = false;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we're switching to have header-rows enabled, then make sure that no columns w/ duplicated headers are selected //
// strategy to do this: build new lists of both required & additional fields - and track if we had to change any //
// column indexes (set to null) - add a warning to them, and only replace the arrays if there were changes. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (newValue)
{
for (let field of this.requiredFields)
{
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newRequiredFields.push(newField);
anyChangesToRequiredFields = true;
}
else
{
newRequiredFields.push(field);
}
}
for (let field of this.additionalFields)
{
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newAdditionalFields.push(newField);
anyChangesToAdditionalFields = true;
}
else
{
newAdditionalFields.push(field);
}
}
}
if (anyChangesToRequiredFields)
{
this.requiredFields = newRequiredFields;
}
if (anyChangesToAdditionalFields)
{
this.additionalFields = newAdditionalFields;
}
}
}
@ -475,6 +649,8 @@ export class FileDescription
headerLetters: string[];
bodyValuesPreview: string[][];
duplicateHeaderIndexes: boolean[];
// todo - just get this from the profile always - it's not part of the file per-se
hasHeaderRow: boolean = true;
@ -486,6 +662,18 @@ export class FileDescription
this.headerValues = headerValues;
this.headerLetters = headerLetters;
this.bodyValuesPreview = bodyValuesPreview;
this.duplicateHeaderIndexes = [];
const usedLabels: { [label: string]: boolean } = {};
for (let i = 0; i < headerValues.length; i++)
{
const label = headerValues[i];
if (usedLabels[label])
{
this.duplicateHeaderIndexes[i] = true;
}
usedLabels[label] = true;
}
}
@ -517,21 +705,85 @@ export class FileDescription
/***************************************************************************
**
***************************************************************************/
public getPreviewValues(columnIndex: number): string[]
public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[]
{
if (columnIndex == undefined)
{
return [];
}
if (this.hasHeaderRow)
function getTypedValue(value: any): string
{
return (this.bodyValuesPreview[columnIndex]);
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (value && value.string)
{
switch (fieldType)
{
case QFieldType.BOOLEAN:
{
return value.bool;
}
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
case QFieldType.PASSWORD:
{
return value.string;
}
case QFieldType.INTEGER:
case QFieldType.LONG:
{
return value.integer;
}
case QFieldType.DECIMAL:
{
return value.decimal;
}
case QFieldType.DATE:
{
return value.date;
}
case QFieldType.TIME:
{
return value.time;
}
case QFieldType.DATE_TIME:
{
return value.dateTime;
}
case QFieldType.BLOB:
return ""; // !!
}
}
return (`${value}`);
}
else
const valueArray: string[] = [];
if (!this.hasHeaderRow)
{
return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]);
const typedValue = getTypedValue(this.headerValues[columnIndex]);
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
for (let value of this.bodyValuesPreview[columnIndex])
{
const typedValue = getTypedValue(value);
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
return (valueArray);
}
}

View File

@ -117,6 +117,11 @@ export default class QQueryColumns
{
const [field, tableForField] = TableUtils.getFieldAndTable(table, fieldName)
if(!field)
{
console.warn(`Couldn't find field ${fieldName} in tableMetaData - so not adding a column for it`);
}
let column: Column;
if(tableForField.name == table.name)
{

View File

@ -72,6 +72,7 @@ import {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"
import BaseLayout from "qqq/layouts/BaseLayout";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -114,9 +115,14 @@ let formikSetTouched = ({}: any, touched: boolean): void =>
const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {};
export interface SubFormPreSubmitCallbackResultType {maySubmit: boolean; values: {[name: string]: any}}
export interface SubFormPreSubmitCallbackResultType
{
maySubmit: boolean;
values: { [name: string]: any };
}
type SubFormPreSubmitCallback = () => SubFormPreSubmitCallbackResultType;
type SubFormPreSubmitCallbackWithName = {name: string, callback: SubFormPreSubmitCallback}
type SubFormPreSubmitCallbackWithName = { name: string, callback: SubFormPreSubmitCallback }
function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, isReport, recordIds, closeModalHandler, forceReInit, overrideLabel}: Props): JSX.Element
{
@ -161,7 +167,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const [previouslySeenUpdatedFieldMetaDataMap, setPreviouslySeenUpdatedFieldMetaDataMap] = useState(new Map<string, QFieldMetaData>);
const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } });
const [controlCallbacks, setControlCallbacks] = useState({} as {[name: string]: () => void});
const [controlCallbacks, setControlCallbacks] = useState({} as { [name: string]: () => void });
const [subFormPreSubmitCallbacks, setSubFormPreSubmitCallbacks] = useState([] as SubFormPreSubmitCallbackWithName[]);
const {pageHeader, recordAnalytics, setPageHeader, helpHelpActive} = useContext(QContext);
@ -237,7 +243,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const bulkLoadFileMappingFormRef = useRef();
const bulkLoadValueMappingFormRef = useRef();
const bulkLoadProfileFormRef = useRef();
const [bulkLoadValueMappingFormFields, setBulkLoadValueMappingFormFields] = useState([] as any[])
const [bulkLoadValueMappingFormFields, setBulkLoadValueMappingFormFields] = useState([] as any[]);
const doesStepHaveComponent = (step: QFrontendStepMetaData, type: QComponentType): boolean =>
{
@ -699,10 +705,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_FILE_MAPPING_FORM))
{
if(bulkLoadFileMappingFormRef?.current)
if (bulkLoadFileMappingFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadFileMappingForm", bulkLoadFileMappingFormRef?.current?.preSubmit)
addSubFormPreSubmitCallbacks("bulkLoadFileMappingForm", bulkLoadFileMappingFormRef?.current?.preSubmit);
}
}
@ -711,10 +717,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
/////////////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM))
{
if(bulkLoadValueMappingFormRef?.current)
if (bulkLoadValueMappingFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadValueMappingForm", bulkLoadValueMappingFormRef?.current?.preSubmit)
addSubFormPreSubmitCallbacks("bulkLoadValueMappingForm", bulkLoadValueMappingFormRef?.current?.preSubmit);
}
}
@ -723,10 +729,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
///////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_PROFILE_FORM))
{
if(bulkLoadProfileFormRef?.current)
if (bulkLoadProfileFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadProfileFormRef", bulkLoadProfileFormRef?.current?.preSubmit)
addSubFormPreSubmitCallbacks("bulkLoadProfileFormRef", bulkLoadProfileFormRef?.current?.preSubmit);
}
}
@ -1032,9 +1038,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
<BulkLoadFileMappingForm
processValues={processValues}
tableMetaData={tableMetaData}
processMetaData={processMetaData}
metaData={qInstance}
ref={bulkLoadFileMappingFormRef}
setActiveStepLabel={setActiveStepLabel}
frontendStep={activeStep}
/>
)
}
@ -1296,7 +1304,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
/////////////////////////////////////////////////////////////////
// Help make this component's fields work with our formik form //
/////////////////////////////////////////////////////////////////
if(activeStep && doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM))
if (activeStep && doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM))
{
const fileValues = processValues.fileValues ?? [];
const valueMapping = processValues.valueMapping ?? {};
@ -1312,22 +1320,22 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
for (let i = 0; i < fileValues.length; i++)
{
const dynamicField = DynamicFormUtils.getDynamicField(qFieldMetaData);
const wrappedField: any = {};
const wrappedField: any = {};
wrappedField[field.name] = dynamicField;
DynamicFormUtils.addPossibleValueProps(wrappedField, [field], fieldTableName, null, null);
const initialValue = valueMapping[fileValues[i]];
if(dynamicField.possibleValueProps)
if (dynamicField.possibleValueProps)
{
dynamicField.possibleValueProps.initialDisplayValue = mappedValueLabels[initialValue]
dynamicField.possibleValueProps.initialDisplayValue = mappedValueLabels[initialValue];
}
addField(`${fieldFullName}.value.${i}`, dynamicField, initialValue, null)
addField(`${fieldFullName}.value.${i}`, dynamicField, initialValue, null);
fieldsForComponent.push(dynamicField);
}
setBulkLoadValueMappingFormFields(fieldsForComponent)
setBulkLoadValueMappingFormFields(fieldsForComponent);
}
if (Object.keys(dynamicFormFields).length > 0)
@ -1520,15 +1528,15 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
***************************************************************************/
function addSubFormPreSubmitCallbacks(name: string, callback: SubFormPreSubmitCallback)
{
if(subFormPreSubmitCallbacks.findIndex(c => c.name == name) == -1)
if (subFormPreSubmitCallbacks.findIndex(c => c.name == name) == -1)
{
const newCallbacks: SubFormPreSubmitCallbackWithName[] = []
for(let i = 0; i < subFormPreSubmitCallbacks.length; i++)
const newCallbacks: SubFormPreSubmitCallbackWithName[] = [];
for (let i = 0; i < subFormPreSubmitCallbacks.length; i++)
{
newCallbacks[i] = subFormPreSubmitCallbacks[i];
}
newCallbacks.push({name, callback})
setSubFormPreSubmitCallbacks(newCallbacks)
newCallbacks.push({name, callback});
setSubFormPreSubmitCallbacks(newCallbacks);
}
}
@ -1618,7 +1626,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setRenderedWidgets({});
setSubFormPreSubmitCallbacks([]);
setQJobRunning(null);
setBackStepName(qJobComplete.backStep)
setBackStepName(qJobComplete.backStep);
if (formikSetFieldValueFunction)
{
@ -1813,8 +1821,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setProcessMetaData(processMetaData);
setSteps(processMetaData.frontendSteps);
recordAnalytics({location: window.location, title: "Process: " + processMetaData?.label});
recordAnalytics({category: "processEvents", action: "startProcess", label: processMetaData?.label});
doRecordAnalytics({location: window.location, title: "Process: " + processMetaData?.label});
doRecordAnalytics({category: "processEvents", action: "startProcess", label: processMetaData?.label});
if (processMetaData.tableName && !tableMetaData)
{
@ -1836,6 +1844,20 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
return;
}
if (urlSearchParams.get("defaultProcessValues"))
{
if (!defaultProcessValues)
{
defaultProcessValues = {};
}
const values = JSON.parse(urlSearchParams.get("defaultProcessValues"));
for (let key in values)
{
defaultProcessValues[key] = values[key];
}
}
if (defaultProcessValues)
{
for (let key in defaultProcessValues)
@ -1878,7 +1900,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setTimeout(async () =>
{
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
doRecordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
const processResponse = await qController.processStep(
processName,
@ -1898,7 +1920,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{
setTimeout(async () =>
{
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
doRecordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
const processResponse = await Client.getInstance().processStep(
processName,
@ -1922,20 +1944,20 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
///////////////////////////////////////////////////////////////
// run any sub-form pre-submit callbacks that are registered //
///////////////////////////////////////////////////////////////
for(let i = 0; i < subFormPreSubmitCallbacks.length; i++)
for (let i = 0; i < subFormPreSubmitCallbacks.length; i++)
{
const {maySubmit, values: moreValues} = subFormPreSubmitCallbacks[i].callback();
if(!maySubmit)
if (!maySubmit)
{
console.log(`May not submit form, per callback: ${subFormPreSubmitCallbacks[i].name}`);
return;
}
if(moreValues)
if (moreValues)
{
for (let key in moreValues)
{
values[key] = moreValues[key]
values[key] = moreValues[key];
}
}
}
@ -2010,7 +2032,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////////////////////////////////////////////////
setLoadingRecords(true);
}
};
/*******************************************************************************
@ -2039,6 +2061,21 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
};
/***************************************************************************
**
***************************************************************************/
function doRecordAnalytics(model: AnalyticsModel)
{
try
{
recordAnalytics(model);
}
catch (e)
{
console.log(`Error recording analytics: ${e}`);
}
}
const formStyles: any = {};
if (isWidget)
{
@ -2220,7 +2257,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (isModal)
{
return (
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}} id="modalProcessScrollContainer">
{body}
</Box>
);

View File

@ -20,6 +20,7 @@
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {ApiVersion} from "@kingsrook/qqq-frontend-core/lib/controllers/QControllerV1";
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
@ -69,6 +70,7 @@ import RecordQueryView from "qqq/models/query/RecordQueryView";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import ColumnStats from "qqq/pages/records/query/ColumnStats";
import DataGridUtils from "qqq/utils/DataGridUtils";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
@ -87,22 +89,25 @@ export type QueryScreenUsage = "queryScreen" | "reportSetup"
interface Props
{
table?: QTableMetaData;
launchProcess?: QProcessMetaData;
usage?: QueryScreenUsage;
isModal?: boolean;
isPreview?: boolean;
initialQueryFilter?: QQueryFilter;
initialColumns?: QQueryColumns;
allowVariables?: boolean;
table?: QTableMetaData,
apiVersion?: ApiVersion,
launchProcess?: QProcessMetaData,
usage?: QueryScreenUsage,
isModal?: boolean,
isPreview?: boolean,
initialQueryFilter?: QQueryFilter,
initialColumns?: QQueryColumns,
allowVariables?: boolean,
omitExposedJoins?: string[]
}
///////////////////////////////////////////////////////
// define possible values for our pageState variable //
///////////////////////////////////////////////////////
type PageState = "initial" | "loadingMetaData" | "loadedMetaData" | "loadingView" | "loadedView" | "preparingGrid" | "ready";
type PageState = "initial" | "loadingMetaData" | "loadedMetaData" | "loadingView" | "loadedView" | "preparingGrid" | "ready" | "error";
const qController = Client.getInstance();
const qControllerV1 = Client.getInstanceV1();
/*******************************************************************************
** function to produce standard version of the screen while we're "loading"
@ -126,7 +131,7 @@ const getLoadingScreen = (isModal: boolean) =>
**
** Yuge component. The best. Lots of very smart people are saying so.
*******************************************************************************/
const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
const RecordQuery = forwardRef(({table, apiVersion, usage, isModal, isPreview, allowVariables, initialQueryFilter, initialColumns, omitExposedJoins}: Props, ref) =>
{
const tableName = table.name;
const [searchParams] = useSearchParams();
@ -933,7 +938,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
}
}
recordAnalytics({category: "tableEvents", action: "query", label: tableMetaData.label});
doRecordAnalytics({category: "tableEvents", action: "query", label: tableMetaData.label});
console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`);
setLoading(true);
@ -978,7 +983,8 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
}
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]) =>
qControllerV1.count(tableName, apiVersion, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) =>
{
console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`);
countResults[thisQueryId] = [];
@ -997,7 +1003,8 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
setLastFetchedQFilterJSON(JSON.stringify(queryFilter));
setLastFetchedVariant(tableVariant);
qController.query(tableName, filterForBackend, queryJoins, tableVariant).then((results) =>
// qController.query(tableName, filterForBackend, queryJoins, tableVariant).then((results) =>
qControllerV1.query(tableName, apiVersion, filterForBackend, queryJoins, tableVariant).then((results) =>
{
console.log(`Received results for query ${thisQueryId}`);
queryResults[thisQueryId] = results;
@ -1103,7 +1110,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
////////////////////////////////
// make the rows for the grid //
////////////////////////////////
const rows = DataGridUtils.makeRows(results, tableMetaData);
const rows = DataGridUtils.makeRows(results, tableMetaData, tableVariant);
setRows(rows);
setLoading(false);
@ -1140,6 +1147,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
const handlePageNumberChange = (page: number) =>
{
setPageNumber(page);
setLoading(true);
};
/*******************************************************************************
@ -1148,6 +1156,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
const handleRowsPerPageChange = (size: number) =>
{
setRowsPerPage(size);
setLoading(true);
view.rowsPerPage = size;
doSetView(view);
@ -1612,6 +1621,22 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
*******************************************************************************/
const processClicked = (process: QProcessMetaData) =>
{
if (process.minInputRecords != null && process.minInputRecords > 0 && getNoOfSelectedRecords() === 0)
{
setAlertContent(`No records were selected for the process: ${process.label}`);
return;
}
else if (process.minInputRecords != null && getNoOfSelectedRecords() < process.minInputRecords)
{
setAlertContent(`Too few records were selected for the process: ${process.label}. A minimum of ${process.minInputRecords} is required.`);
return;
}
else if (process.maxInputRecords != null && getNoOfSelectedRecords() > process.maxInputRecords)
{
setAlertContent(`Too many records were selected for the process: ${process.label}. A maximum of ${process.maxInputRecords} is allowed.`);
return;
}
// todo - let the process specify that it needs initial rows - err if none selected.
// alternatively, let a process itself have an initial screen to select rows...
openModalProcess(process);
@ -1655,8 +1680,9 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
{
if (savedViewRecord == null)
{
console.log("doSetCurrentView called with a null view record - calling doClearCurrentSavedView instead.");
console.log("doSetCurrentView called with a null view record - calling doClearCurrentSavedView, and activating tableDefaultView instead.");
doClearCurrentSavedView();
activateView(buildTableDefaultView(tableMetaData));
return;
}
@ -1707,7 +1733,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
{
if (selectedSavedViewId != null)
{
recordAnalytics({category: "tableEvents", action: "activateSavedView", label: tableMetaData.label});
doRecordAnalytics({category: "tableEvents", action: "activateSavedView", label: tableMetaData.label});
//////////////////////////////////////////////
// fetch, then activate the selected filter //
@ -1724,7 +1750,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
/////////////////////////////////
// this is 'new view' - right? //
/////////////////////////////////
recordAnalytics({category: "tableEvents", action: "activateNewView", label: tableMetaData.label});
doRecordAnalytics({category: "tableEvents", action: "activateNewView", label: tableMetaData.label});
//////////////////////////////
// wipe away the saved view //
@ -1752,7 +1778,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
console.error("Could not retrieve saved filter: " + jobError.userFacingError);
console.error("Could not retrieve saved view: " + jobError.userFacingError);
setAlertContent("There was an error loading the selected view.");
}
else
@ -2418,23 +2444,33 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
const metaData = await qController.loadMetaData();
setMetaData(metaData);
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
setTableLabel(tableMetaData.label);
try
{
// const tableMetaData = await qController.loadTableMetaData(tableName);
const tableMetaData = await qControllerV1.loadTableMetaData(tableName, apiVersion);
setTableMetaData(tableMetaData);
setTableLabel(tableMetaData.label);
recordAnalytics({location: window.location, title: "Query: " + tableMetaData.label});
doRecordAnalytics({location: window.location, title: "Query: " + tableMetaData.label});
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)
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)
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. //
// but also used when user selects new-view from the view menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const newDefaultView = buildTableDefaultView(tableMetaData);
setTableDefaultView(newDefaultView);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. //
// but also used when user selects new-view from the view menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const newDefaultView = buildTableDefaultView(tableMetaData);
setTableDefaultView(newDefaultView);
setPageState("loadedMetaData");
setPageState("loadedMetaData");
}
catch (e)
{
setPageState("error");
//@ts-ignore e.message
setAlertContent("Error loading table: " + e?.message ?? "Details not available.");
}
})();
}
@ -2702,6 +2738,16 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
);
}
//////////////////////////////////////////////
// render an error screen (alert) if needed //
//////////////////////////////////////////////
if (pageState == "error")
{
console.log(`page state is ${pageState}... rendering an alert...`);
const errorBody = <Box py={3}><Alert severity="error">{alertContent}</Alert></Box>;
return isModal ? errorBody : <BaseLayout>{errorBody}</BaseLayout>;
}
///////////////////////////////////////////////////////////
// render a loading screen if the page state isn't ready //
///////////////////////////////////////////////////////////
@ -2789,6 +2835,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
idPrefix="columns"
tableMetaData={tableMetaData}
showTableHeaderEvenIfNoExposedJoins={true}
omitExposedJoins={omitExposedJoins}
placeholder="Search Fields"
buttonProps={{sx: columnMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>view_week_outline</Icon> Columns ({view.queryColumns.getVisibleColumnCount()}) <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
@ -2799,6 +2846,22 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
</Box>);
};
/***************************************************************************
**
***************************************************************************/
function doRecordAnalytics(model: AnalyticsModel)
{
try
{
recordAnalytics(model);
}
catch (e)
{
console.log(`Error recording analytics: ${e}`);
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// these numbers help set the height of the grid (so page won't scroll) based on space above & below it //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -2914,6 +2977,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
setMode={doSetMode}
savedViewsComponent={savedViewsComponent}
columnMenuComponent={buildColumnMenu()}
omitExposedJoins={omitExposedJoins}
/>
}
@ -2939,7 +3003,8 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
metaData: metaData,
queryFilter: queryFilter,
updateFilter: doSetQueryFilter,
allowVariables: allowVariables
allowVariables: allowVariables,
omitExposedJoins: omitExposedJoins,
}
}}
localeText={{
@ -3036,6 +3101,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
RecordQuery.defaultProps = {
table: null,
apiVersion: null,
usage: "queryScreen",
launchProcess: null,
isModal: false,

View File

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

View File

@ -92,9 +92,9 @@ const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
/*******************************************************************************
**
*******************************************************************************/
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: {label?: SxProps, value?: SxProps})
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: {label?: SxProps, value?: SxProps}, tableVariant?: QTableVariant)
{
return <Box key={key} display="flex" flexDirection="column" py={1} pr={2}>
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
{
fieldNames.map((fieldName: string) =>
{
@ -103,6 +103,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
if (field != null)
{
let label = field.label;
let gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
@ -111,22 +112,22 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default", ...(styleOverrides?.label ?? {})}}>{label}:</Typography>;
return (
<Box key={fieldName} flexDirection="row" pr={2}>
<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
<>
{
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
</Typography>
</>
</Box>
</Grid>
);
}
})
}
</Box>;
</Grid>;
}
@ -439,6 +440,34 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
};
/***************************************************************************
**
***************************************************************************/
function getGenericProcesses(metaData: QInstance)
{
const genericProcesses: QProcessMetaData[] = [];
const materialDashboardInstanceMetaData = metaData?.supplementalInstanceMetaData?.get("materialDashboard");
if (materialDashboardInstanceMetaData)
{
const processNamesToAddToAllQueryAndViewScreens = materialDashboardInstanceMetaData.processNamesToAddToAllQueryAndViewScreens;
if (processNamesToAddToAllQueryAndViewScreens)
{
for (let processName of processNamesToAddToAllQueryAndViewScreens)
{
genericProcesses.push(metaData?.processes?.get(processName));
}
}
}
else
{
////////////////
// deprecated //
////////////////
genericProcesses.push(metaData?.processes.get("runRecordScript"));
}
return genericProcesses;
}
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
@ -471,11 +500,16 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
// load processes that the routing needs to respect //
//////////////////////////////////////////////////////
const allTableProcesses = ProcessUtils.getProcessesForTable(metaData, tableName, true); // these include hidden ones (e.g., to find the bulks)
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
if (runRecordScriptProcess)
const genericProcesses = getGenericProcesses(metaData);
for (let genericProcess of genericProcesses)
{
allTableProcesses.unshift(runRecordScriptProcess);
if (genericProcess)
{
allTableProcesses.unshift(genericProcess);
}
}
setAllTableProcesses(allTableProcesses);
if (launchingProcess)
@ -597,7 +631,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
// for a section with field names, render the field values. //
// for the T1 section, the "wrapper" will come out below - but for other sections, produce a wrapper too. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record);
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record, undefined, undefined, tableVariant);
if (section.tier === "T1")
{
@ -725,7 +759,6 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
let hasEditOrDelete = (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) || (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission);
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
const renderActionsMenu = (
<Menu
@ -784,11 +817,14 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
))}
{(tableProcesses?.length > 0 || hasEditOrDelete) && <Divider />}
{
runRecordScriptProcess &&
<MenuItem key={runRecordScriptProcess.name} onClick={() => processClicked(runRecordScriptProcess)}>
<ListItemIcon><Icon>{runRecordScriptProcess.iconName ?? "arrow_forward"}</Icon></ListItemIcon>
{runRecordScriptProcess.label}
</MenuItem>
getGenericProcesses(metaData).map((process) =>
(
process &&
<MenuItem key={process.name} onClick={() => processClicked(process)}>
<ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>
{process.label}
</MenuItem>
))
}
<MenuItem onClick={() => navigate("dev")}>
<ListItemIcon><Icon>code</Icon></ListItemIcon>
@ -968,7 +1004,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
{
notFoundMessage
?
<Alert color="error" sx={{mb: 3}}>{notFoundMessage}</Alert>
<Alert color="error" sx={{mb: 3}} icon={<Icon>warning</Icon>}>{notFoundMessage}</Alert>
:
<Box pb={3}>
{
@ -1045,16 +1081,19 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
</React.Fragment>
)) : null}
</Grid>
<Box component="form" p={3}>
<Grid container justifyContent="flex-end" spacing={3}>
{
table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission && <QDeleteButton onClickHandler={handleClickDeleteButton} />
}
{
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission && <QEditButton />
}
</Grid>
</Box>
{
tableMetaData && record && ((table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) || (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)) &&
<Box component="div" p={3} className={"stickyBottomButtonBar"}>
<Grid container justifyContent="flex-end" spacing={3}>
{
table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission && <QDeleteButton onClickHandler={handleClickDeleteButton} />
}
{
table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission && <QEditButton />
}
</Grid>
</Box>
}
</Grid>
</Grid>

View File

@ -303,10 +303,15 @@ input[type="search"]::-webkit-search-results-decoration
.MuiTablePagination-root .MuiSvgIcon-root
{
display: inline;
color: gray;
color: rgba(0, 0, 0, 0.54);
right: 0.125rem;
}
.MuiTablePagination-root .Mui-disabled .MuiSvgIcon-root
{
color: rgba(0, 0, 0, 0.16);
}
.devDocumentation ul > li
{
margin-left: 30px;
@ -748,35 +753,54 @@ input[type="search"]::-webkit-search-results-decoration
padding: 8px 0;
}
.helpContentAlert.success
.helpContentAlert.info,
.banner.info
{
background-color: rgb(234, 242, 255);
color: rgb(20, 51, 102);
}
.helpContentAlert.info .MuiAlert-icon .material-icons-round,
.banner.info .MuiAlert-icon .material-icons-round
{
color: #0062FF;
}
.helpContentAlert.success,
.banner.success
{
background-color: rgb(240, 248, 241);
color: rgb(44, 76, 46);
}
.helpContentAlert.success .MuiAlert-icon .material-icons-round
.helpContentAlert.success .MuiAlert-icon .material-icons-round,
.banner.success .MuiAlert-icon .material-icons-round
{
color: #4CAF50;
}
.helpContentAlert.warning
.helpContentAlert.warning,
.banner.warning
{
background-color: rgb(254, 245, 234);
color: rgb(100, 65, 20);
}
.helpContentAlert.warning .MuiAlert-icon .material-icons-round
.helpContentAlert.warning .MuiAlert-icon .material-icons-round,
.banner.warning .MuiAlert-icon .material-icons-round
{
color: #fb8c00;
}
.helpContentAlert.error
.helpContentAlert.error,
.banner.error
{
background-color: rgb(254, 239, 238);
color: rgb(98, 41, 37);
}
.helpContentAlert.error .MuiAlert-icon .material-icons-round
.helpContentAlert.error .MuiAlert-icon .material-icons-round,
.banner.error .MuiAlert-icon .material-icons-round
{
color: #F44335;
}
@ -817,4 +841,27 @@ input[type="search"]::-webkit-search-results-decoration
max-width: 100% !important;
flex-grow: 1 !important;
}
}
}
.stickyBottomButtonBar
{
padding-bottom: 1rem !important;
padding-right: 0 !important;
margin-bottom: -4rem !important;
margin-top: -1.5rem !important;
position: sticky;
bottom: 0;
background: linear-gradient(to bottom, transparent 0, #f0f2f5 4px);
z-index: 10; /* have needed a little here, e.g. to get above MuiDataGrid-overlay and ACE */
}
.modalBottomButtonBar
{
padding-bottom: 0 !important;
padding-right: 0 !important;
}
.stickyBottomButtonBar>.MuiGrid-container
{
padding-top: 1rem;
}

View File

@ -24,6 +24,7 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF
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 {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
@ -70,7 +71,7 @@ export default class DataGridUtils
/*******************************************************************************
**
*******************************************************************************/
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, allowEmptyId = false): GridRowsProp[] =>
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant, allowEmptyId = false): GridRowsProp[] =>
{
const fields = [...tableMetaData.fields.values()];
const rows = [] as any[];
@ -82,7 +83,7 @@ export default class DataGridUtils
fields.forEach((field) =>
{
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
row[field.name] = ValueUtils.getDisplayValue(field, record, "query", undefined, tableVariant);
});
if (tableMetaData.exposedJoins)
@ -97,7 +98,7 @@ export default class DataGridUtils
fields.forEach((field) =>
{
let fieldName = join.joinTable.name + "." + field.name;
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName);
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName, tableVariant);
});
}
}

View File

@ -102,7 +102,7 @@ export default class GoogleAnalyticsUtils
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"))
if (this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID"))
{
this.active = true;

View File

@ -20,6 +20,7 @@
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QControllerV1} from "@kingsrook/qqq-frontend-core/lib/controllers/QControllerV1";
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
/*******************************************************************************
@ -29,6 +30,7 @@ import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException
class Client
{
private static qController: QController;
private static qControllerV1: QControllerV1;
private static unauthorizedCallback: () => void;
private static handleException(exception: QException)
@ -54,6 +56,22 @@ class Client
return this.qController;
}
public static getInstanceV1(path: string = "/qqq/v1")
{
if (this.qControllerV1 == null)
{
this.qControllerV1 = new QControllerV1(path, this.handleException);
}
return this.qControllerV1;
}
public static setGotAuthenticationInAllControllers()
{
Client.getInstance().setGotAuthentication();
Client.getInstanceV1().setGotAuthentication();
}
static setUnauthorizedCallback(unauthorizedCallback: () => void)
{
Client.unauthorizedCallback = unauthorizedCallback;

View File

@ -108,6 +108,12 @@ class FilterUtils
const criteria = queryFilter.criteria[i];
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
if(!field)
{
console.warn(`Field ${criteria.fieldName} not found in tableMetaData - unable to clean up values for it..`);
return;
}
let values = criteria.values;
let hasFilterVariable = false;
@ -133,7 +139,7 @@ class FilterUtils
}
else
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, "filter");
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, undefined, "filter");
}
}
@ -401,21 +407,21 @@ class FilterUtils
{
const expression = new ThisOrLastPeriodExpression(value);
let startOfPrefix = "";
if (fieldMetaData.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
if (fieldMetaData?.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
{
startOfPrefix = "start of ";
}
labels.push(`${startOfPrefix}${expression.toString()}`);
}
else if (fieldMetaData.type == QFieldType.BOOLEAN)
else if (fieldMetaData?.type == QFieldType.BOOLEAN)
{
labels.push(value == true ? "yes" : "no");
}
else if (fieldMetaData.type == QFieldType.DATE_TIME)
else if (fieldMetaData?.type == QFieldType.DATE_TIME)
{
labels.push(ValueUtils.formatDateTime(value));
}
else if (fieldMetaData.type == QFieldType.DATE)
else if (fieldMetaData?.type == QFieldType.DATE)
{
labels.push(ValueUtils.formatDate(value));
}

View File

@ -0,0 +1,361 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert} from "@mui/material";
import Box from "@mui/material/Box";
import Modal from "@mui/material/Modal";
import {ThemeProvider} from "@mui/material/styles";
import {Formik} from "formik";
import QContext from "QContext";
import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDButton from "qqq/components/legacy/MDButton";
import theme from "qqq/components/legacy/Theme";
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import React, {ReactElement, ReactNode, useContext, useEffect, useState} from "react";
import {BrowserRouter} from "react-router-dom";
import * as Yup from "yup";
// todo - deploy this interface somehow out of this file
export interface QFMDBridge
{
qController?: QController;
makeAlert: (text: string, color: string) => JSX.Element;
makeButton: (label: string, onClick: () => void, extra?: { [key: string]: any }) => JSX.Element;
makeForm: (fields: QFieldMetaData[], record: QRecord, handleChange: (fieldName: string, newValue: any) => void, handleSubmit: (values: any) => void) => JSX.Element;
makeModal: (children: ReactElement, onClose?: (setIsOpen: (isOpen: boolean) => void, event: {}, reason: "backdropClick" | "escapeKeyDown") => void) => JSX.Element;
makeWidget: (widgetName: string, tableName?: string, entityPrimaryKey?: string, record?: QRecord, actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean) => JSX.Element;
}
/***************************************************************************
** Component to generate a form for the QFMD Bridge
***************************************************************************/
interface QFMDBridgeFormProps
{
fields: QFieldMetaData[],
record: QRecord,
handleChange: (fieldName: string, newValue: any) => void,
handleSubmit: (values: any) => void
}
QFMDBridgeForm.defaultProps = {};
function QFMDBridgeForm({fields, record, handleChange, handleSubmit}: QFMDBridgeFormProps): JSX.Element
{
const initialValues: any = {};
for (let field of fields)
{
initialValues[field.name] = record.values.get(field.name);
if(initialValues[field.name] === undefined && field.defaultValue !== undefined)
{
initialValues[field.name] = field.defaultValue;
}
}
const [lastValues, setLastValues] = useState(initialValues);
const [loaded, setLoaded] = useState(false);
///////////////////////////////////////////////////////////////////////////////
// store reference to record display values in a state var - see usage below //
///////////////////////////////////////////////////////////////////////////////
const [recordDisplayValues, setRecordDisplayValues] = useState(record?.displayValues ?? new Map<string, string>())
useEffect(() =>
{
(async () =>
{
const qController = Client.getInstance();
for (let field of fields)
{
const value = record.values.get(field.name);
if (field.possibleValueSourceName && value)
{
const possibleValues = await qController.possibleValues(null, null, field.possibleValueSourceName, null, [value], [], record.values, "form");
if (possibleValues && possibleValues.length > 0)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// originally, we put this in record.displayValues, but, sometimes that would then be empty at the usage point below... //
// this works, so, we'll go with it //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
recordDisplayValues.set(field.name, possibleValues[0].label)
setRecordDisplayValues(recordDisplayValues);
}
}
}
setLoaded(true);
})();
}, []);
if (!loaded)
{
return (<Box py={"1rem"}>Loading...</Box>);
}
const {
dynamicFormFields,
formValidations,
} = DynamicFormUtils.getFormData(fields);
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fields, null, null, recordDisplayValues);
const otherValuesMap = new Map<string, any>();
record.values.forEach((value, key) => otherValuesMap.set(key, value));
for (let fieldName in dynamicFormFields)
{
const dynamicFormField = dynamicFormFields[fieldName];
if (dynamicFormField.possibleValueProps)
{
dynamicFormField.possibleValueProps.otherValues = otherValuesMap;
}
}
/////////////////////////////////////////////////////////////////////////////////
// re-introduce these two context providers, in case the child calls this //
// method under a different root... maybe this should be optional per a param? //
/////////////////////////////////////////////////////////////////////////////////
return (<MaterialUIControllerProvider>
<ThemeProvider theme={theme}>
<Formik initialValues={initialValues} validationSchema={Yup.object().shape(formValidations)} onSubmit={handleSubmit}>
{({values, errors, touched}) =>
{
const formData: any = {};
formData.values = values;
formData.touched = touched;
formData.errors = errors;
formData.formFields = dynamicFormFields;
try
{
let anyDiffs = false;
for (let fieldName in values)
{
const value = values[fieldName];
if (lastValues[fieldName] != value)
{
handleChange(fieldName, value);
lastValues[fieldName] = value;
anyDiffs = true;
}
}
if (anyDiffs)
{
setLastValues(lastValues);
}
}
catch (e)
{
console.error(e);
}
return (<QDynamicForm formData={formData} record={record} />);
}}
</Formik>
</ThemeProvider>
</MaterialUIControllerProvider>);
}
/***************************************************************************
** Component to render a widget for the QFMD Bridge
***************************************************************************/
interface QFMDBridgeWidgetProps
{
widgetName?: string,
tableName?: string,
record?: QRecord,
entityPrimaryKey?: string,
actionCallback?: (data: any, eventValues?: { [p: string]: any }) => boolean
}
QFMDBridgeWidget.defaultProps = {};
function QFMDBridgeWidget({widgetName, tableName, record, entityPrimaryKey, actionCallback}: QFMDBridgeWidgetProps): JSX.Element
{
const qContext = useContext(QContext);
const [ready, setReady] = useState(false);
const [widgetMetaData, setWidgetMetaData] = useState(null as QWidgetMetaData);
const [widgetData, setWidgetData] = useState(null as any);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
useEffect(() =>
{
(async () =>
{
const qController = Client.getInstance();
const qInstance = await qController.loadMetaData();
const queryStringParts: string[] = [];
for (let key of record?.values?.keys())
{
queryStringParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(record.values.get(key))}`);
}
setWidgetMetaData(qInstance.widgets.get(widgetName));
setWidgetData(await qController.widget(widgetName, queryStringParts.join("&")));
setReady(true);
})();
}, []);
if (!ready)
{
return (<Box py={"1rem"}>Loading...</Box>);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////
// internally in some widgets, useNavigate happens... so we must re-introduce the browser-router context //
// plus the contexts too, as indicated. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////
return (<BrowserRouter>
<MaterialUIControllerProvider>
<ThemeProvider theme={theme}>
<QContext.Provider value={{
...qContext,
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
}}>
<div className={`bridgedWidget ${widgetMetaData.type}`}>
<DashboardWidgets tableName={tableName} widgetMetaDataList={[widgetMetaData]} initialWidgetDataList={[widgetData]} record={record} entityPrimaryKey={entityPrimaryKey} omitWrappingGridContainer={true} actionCallback={actionCallback} />
</div>
</QContext.Provider>
</ThemeProvider>
</MaterialUIControllerProvider>
</BrowserRouter>);
}
/***************************************************************************
** Component to render a modal for the QFMD Bridge
***************************************************************************/
interface QFMDBridgeModalProps
{
children: ReactNode;
onClose?: (setIsOpen: (isOpen: boolean) => void, event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}
QFMDBridgeModal.defaultProps = {};
function QFMDBridgeModal({children, onClose}: QFMDBridgeModalProps): JSX.Element
{
const [isOpen, setIsOpen] = useState(true);
function closeModalProcess(event: {}, reason: "backdropClick" | "escapeKeyDown")
{
if (onClose)
{
onClose(setIsOpen, event, reason);
}
else
{
setIsOpen(false);
}
}
return (
<Modal open={isOpen} onClose={(event, reason) => closeModalProcess(event, reason)}>
<Box className="bridgeModal" height="calc(100vh)">
{children}
</Box>
</Modal>
);
}
/***************************************************************************
** Component to render an alert for the QFMD Bridge
***************************************************************************/
interface QFMDBridgeAlertProps
{
color: string,
children: ReactNode,
mayManuallyClose?: boolean
}
QFMDBridgeAlert.defaultProps = {};
function QFMDBridgeAlert({color, children, mayManuallyClose}: QFMDBridgeAlertProps): JSX.Element
{
const [isOpen, setIsOpen] = useState(true);
function onClose()
{
setIsOpen(false);
}
if (isOpen)
{
//@ts-ignore color
return (<Alert color={color} onClose={mayManuallyClose ? onClose : null}>{children}</Alert>);
}
else
{
return (<React.Fragment />);
}
}
/***************************************************************************
** define the default qfmd bridge object
***************************************************************************/
export const qfmdBridge =
{
qController: Client.getInstance(),
makeButton: (label: string, onClick: () => void, extra?: { [key: string]: any }): JSX.Element =>
{
return (<MDButton {...extra} onClick={onClick} fullWidth>{label}</MDButton>);
},
makeAlert: (text: string, color: string, mayManuallyClose?: boolean): JSX.Element =>
{
return (<QFMDBridgeAlert color={color} mayManuallyClose={mayManuallyClose}>{text}</QFMDBridgeAlert>);
},
makeModal: (children: ReactElement, onClose?: (setIsOpen: (isOpen: boolean) => void, event: {}, reason: "backdropClick" | "escapeKeyDown") => void): JSX.Element =>
{
return (<QFMDBridgeModal onClose={onClose}>{children}</QFMDBridgeModal>);
},
makeWidget: (widgetName: string, tableName?: string, entityPrimaryKey?: string, record?: QRecord, actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean): JSX.Element =>
{
return (<QFMDBridgeWidget widgetName={widgetName} tableName={tableName} record={record} entityPrimaryKey={entityPrimaryKey} actionCallback={actionCallback} />);
},
makeForm: (fields: QFieldMetaData[], record: QRecord, handleChange: (fieldName: string, newValue: any) => void, handleSubmit: (values: any) => void): JSX.Element =>
{
return (<QFMDBridgeForm fields={fields} record={record} handleChange={handleChange} handleSubmit={handleSubmit} />);
}
};

View File

@ -37,7 +37,7 @@ export class SavedBulkLoadProfileUtils
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.field.name;
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
const baseField = baseFieldsMap[fieldName];
if(!compareField)
@ -55,12 +55,13 @@ export class SavedBulkLoadProfileUtils
if (compareField.valueType == "column")
{
const column = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a default value (${baseField.defaultValue}) to using a file column (${column})`);
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a default value (${baseField.defaultValue}) to using a file column ${column ? `(${column})` : ""}`);
}
else if (compareField.valueType == "defaultValue")
{
const column = fileDescription.getColumnNames()[baseField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value (${compareField.defaultValue})`);
const value = compareField.defaultValue;
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value ${value === undefined ? "" : `(${value})`}`);
}
}
else if (baseField.valueType == compareField.valueType && baseField.valueType == "defaultValue")
@ -70,7 +71,8 @@ export class SavedBulkLoadProfileUtils
//////////////////////////////////////////////////
if (baseField.defaultValue != compareField.defaultValue)
{
rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to (${compareField.defaultValue})`);
const value = compareField.defaultValue;
rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to ${value === undefined ? "" : `(${value})`}`);
}
}
else if (baseField.valueType == compareField.valueType && baseField.valueType == "column")
@ -78,25 +80,29 @@ export class SavedBulkLoadProfileUtils
///////////////////////////////////////////
// if we changed the column, report that //
///////////////////////////////////////////
let isDiff = false;
if (fileDescription.hasHeaderRow)
{
if (baseField.headerName != compareField.headerName)
{
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`);
isDiff = true;
}
}
else
{
if (baseField.columnIndex != compareField.columnIndex)
{
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`);
isDiff = true;
}
}
if(isDiff)
{
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from ${baseColumn ? `(${baseColumn})` : "--"} to ${compareColumn ? `(${compareColumn})` : "--"}`);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////
// if the do-value-mapping field changed, report that (note, only if was and still is column-type) //
/////////////////////////////////////////////////////////////////////////////////////////////////////
@ -120,7 +126,7 @@ export class SavedBulkLoadProfileUtils
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.field.name;
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
if(!compareField)
{
@ -292,7 +298,7 @@ export class SavedBulkLoadProfileUtils
{
try
{
const fieldName = bulkLoadField.field.name;
const fieldName = bulkLoadField.getQualifiedName() // todo - does this (and the others calls to this) need suffix?
const valueMappingDiff = this.diffFieldValueMappings(bulkLoadField, baseMapping.valueMappings[fieldName] ?? {}, activeMapping.valueMappings[fieldName] ?? {});
if(valueMappingDiff)

View File

@ -23,6 +23,7 @@ import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Ado
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 {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import "datejs"; // https://github.com/datejs/Datejs
import {Chip, ClickAwayListener, Icon} from "@mui/material";
@ -76,14 +77,14 @@ class ValueUtils
** When you have a field, and a record - call this method to get a string or
** element back to display the field's value.
*******************************************************************************/
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string): string | JSX.Element | JSX.Element[]
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string, tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[]
{
const fieldName = overrideFieldName ?? field.name;
const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined;
const rawValue = record.values ? record.values.get(fieldName) : undefined;
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage);
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant, record, fieldName);
}
@ -91,14 +92,35 @@ class ValueUtils
** When you have a field and a value (either just a raw value, or a raw and
** display value), call this method to get a string Element to display.
*******************************************************************************/
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[]
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant, record?: QRecord, fieldName?: string): string | JSX.Element | JSX.Element[]
{
if (field.hasAdornment(AdornmentType.LINK))
{
const adornment = field.getAdornment(AdornmentType.LINK);
let href = rawValue;
let href = String(rawValue);
let toRecordFromTable = adornment.getValue("toRecordFromTable");
/////////////////////////////////////////////////////////////////////////////////////
// if the link adornment has a 'toRecordFromTableDynamic', then look for a display //
// value named `fieldName`:toRecordFromTableDynamic for the table name. //
/////////////////////////////////////////////////////////////////////////////////////
if(adornment.getValue("toRecordFromTableDynamic"))
{
const toRecordFromTableDynamic = record?.displayValues?.get(fieldName + ":toRecordFromTableDynamic");
if(toRecordFromTableDynamic)
{
toRecordFromTable = toRecordFromTableDynamic;
}
else
{
///////////////////////////////////////////////////////////////////
// if the table name isn't known, then return w/o the adornment. //
///////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
const toRecordFromTable = adornment.getValue("toRecordFromTable");
if (toRecordFromTable)
{
if (ValueUtils.getQInstance())
@ -107,7 +129,7 @@ class ValueUtils
if (!tablePath)
{
console.log("Couldn't find path for table: " + toRecordFromTable);
return (displayValue ?? rawValue);
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
if (!tablePath.endsWith("/"))
@ -199,12 +221,44 @@ class ValueUtils
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
let url = rawValue;
if(tableVariant)
{
url += "?tableVariant=" + encodeURIComponent(JSON.stringify(tableVariant));
}
//////////////////////////////////////////////////////////////////////////////
// if the field has the download adornment with a downloadUrlDynamic value, //
// then get the url from a displayValue of `fieldName`:downloadUrlDynamic. //
//////////////////////////////////////////////////////////////////////////////
if(field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
const adornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD);
let downloadUrlDynamicAdornmentValue = adornment.getValue("downloadUrlDynamic");
if(downloadUrlDynamicAdornmentValue)
{
const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic");
if (downloadUrlDynamicValue)
{
url = downloadUrlDynamicValue;
}
else
{
////////////////////////////////////////////////////////////////
// if the url isn't available, then return w/o the adornment. //
////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
}
return (<BlobComponent field={field} url={url} filename={displayValue} usage={usage} />);
}
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
/*******************************************************************************
** After we know there's no element to be returned (e.g., because no adornment),
** this method does the string formatting.
@ -213,7 +267,13 @@ class ValueUtils
{
if (!displayValue && field.defaultValue)
{
displayValue = field.defaultValue;
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// note, at one point in time, we used a field's default value here if no displayValue... but that feels 100% wrong, //
// e.g., a null field would show up (on a query or view screen) has having some value! //
// not sure if this was maybe supposed to be displayValue = rawValue, but, keep that in mind, and keep this block here //
// in case we run into issues and need to revisit/rethink //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// displayValue = field.defaultValue;
}
if (field.type === QFieldType.DATE_TIME)

View File

@ -0,0 +1,117 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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 Box from "@mui/material/Box";
import {qfmdBridge, QFMDBridge} from "qqq/utils/qqq/QFMDBridge";
import React, {useState} from "react";
// todo - deploy from here!!
interface DynamicComponentProps
{
qfmdBridge?: QFMDBridge;
props?: any;
}
/*******************************************************************************
** hook for working with Dynamically loaded components
**
*******************************************************************************/
export default function useDynamicComponents()
{
const [dynamicComponents, setDynamicComponents] = useState<{ [name: string]: React.FC }>({});
/*******************************************************************************
**
*******************************************************************************/
const loadComponent = async (name: string, url: string) =>
{
try
{
await new Promise((resolve, reject) =>
{
////////////////////////////////////////////////////////
// Dynamically load the bundle by adding a script tag //
////////////////////////////////////////////////////////
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
catch (e)
{
////////////////////////////////////////////////
// if the script can't be loaded log an error //
////////////////////////////////////////////////
console.error(`Error loading bundle from [${url}]`);
}
///////////////////////////////////////////////////////////////////////////////
// Assuming the bundle attaches itself to window.${name} (.${name} again...) //
// (Note: if exported as UMD, you might need to access the default export) //
///////////////////////////////////////////////////////////////////////////////
let component = (window as any)[name]?.[name];
if (!component)
{
console.error(`Component not found on window.${name}`);
component = () => <Box>Error loading {name}</Box>;
}
const newDCs = Object.assign({}, dynamicComponents);
newDCs[name] = component;
setDynamicComponents(newDCs);
};
/***************************************************************************
**
***************************************************************************/
const hasComponentLoaded = (name: string): boolean =>
{
return (!!dynamicComponents[name]);
};
/***************************************************************************
**
***************************************************************************/
const renderComponent = (name: string, props?: any): JSX.Element =>
{
if (dynamicComponents[name])
{
const C: React.FC<DynamicComponentProps> = dynamicComponents[name];
return (<C qfmdBridge={qfmdBridge} props={props} />);
}
else
{
return (<Box>Loading...</Box>);
}
};
return {
loadComponent,
hasComponentLoaded,
renderComponent
};
}

View File

@ -58,4 +58,6 @@ module.exports = function (app)
app.use("/api*", getRequestHandler());
app.use("/*api", getRequestHandler());
app.use("/qqq/*", getRequestHandler());
app.use("/dynamic-qfmd-components/*", getRequestHandler());
app.use("/material-dashboard-backend/*", getRequestHandler());
};

View File

@ -181,7 +181,12 @@ public class QBaseSeleniumTest
.withRouteToFile("/metaData/table/city", "metaData/table/person.json")
.withRouteToFile("/metaData/table/script", "metaData/table/script.json")
.withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json")
.withRouteToFile("/qqq/v1/metaData/table/person", "qqq/v1/metaData/table/person.json")
.withRouteToFile("/qqq/v1/metaData/table/city", "qqq/v1/metaData/table/city.json")
.withRouteToFile("/qqq/v1/metaData/table/script", "qqq/v1/metaData/table/script.json")
.withRouteToFile("/qqq/v1/metaData/table/scriptRevision", "qqq/v1/metaData/table/scriptRevision.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}

View File

@ -103,6 +103,30 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void openCriteriaPasterAndPasteValues(String fieldName, List<String> values)
{
/////////////////////////////////////////////////////////////////////////////
// open the is any of criteria for given field and click the paster button //
/////////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldName).click();
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", "is any of").click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector(".criteriaPasterButton").click();
////////////////////////////////////////
// paste the values into the textarea //
////////////////////////////////////////
qSeleniumLib
.waitForSelector(".criteriaPasterTextArea textarea#outlined-multiline-static")
.sendKeys(String.join("\n", values));
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -46,7 +46,9 @@ public class AppPageNavTest extends QBaseSeleniumTest
.withRouteToString("/widget/QuickSightChartRenderer", """
{"url": "http://www.google.com"}""")
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/city/count", "data/city/count.json");
.withRouteToFile("/data/city/count", "data/city/count.json")
.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json")
.withRouteToFile("/qqq/v1/table/city/count", "qqq/v1/table/city/count.json");
}

View File

@ -42,7 +42,7 @@ public class AssociatedRecordScriptTest extends QBaseSeleniumTest
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/data/person/1", "data/person/1701.json");
qSeleniumJavalin.withRouteToFile("/data/person/1/developer", "data/person/1701.json");
qSeleniumJavalin.withRouteToFile("/data/person/1/developer", "data/person/developer.json");
}

View File

@ -63,6 +63,8 @@ public class BulkEditTest extends QBaseSeleniumTest
qSeleniumJavalin.withRouteToFile("/data/person/count", "data/person/count.json");
qSeleniumJavalin.withRouteToFile("/data/person/query", "data/person/index.json");
qSeleniumJavalin.withRouteToFile("/data/person/variants", "data/person/variants.json");
qSeleniumJavalin.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json");
qSeleniumJavalin.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json");
qSeleniumJavalin.withRouteToString("/processes/person.bulkEdit/74a03a7d-2f53-4784-9911-3a21f7646c43/records", "[]");
}

View File

@ -51,12 +51,15 @@ public class SavedReportTest extends QBaseSeleniumTest
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/metaData/table/savedReport", "metaData/table/savedReport.json")
.withRouteToFile("/qqq/v1/metaData/table/savedReport", "qqq/v1/metaData/table/savedReport.json")
.withRouteToFile("/widget/reportSetupWidget", "widget/reportSetupWidget.json")
.withRouteToFile("/widget/pivotTableSetupWidget", "widget/pivotTableSetupWidget.json")
.withRouteToFile("/data/savedReport/possibleValues/tableName", "data/savedReport/possibleValues/tableName.json")
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json")
.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json")
;
}
@ -93,8 +96,8 @@ public class SavedReportTest extends QBaseSeleniumTest
////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns").click();
qSeleniumJavalin.waitForCapturedPath("/data/person/count");
qSeleniumJavalin.waitForCapturedPath("/data/person/query");
qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/count");
qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/query");
qSeleniumJavalin.endCapture();
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);

View File

@ -53,6 +53,8 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json")
.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");

View File

@ -53,6 +53,8 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json")
.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");

View File

@ -22,13 +22,16 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.assertj.core.api.Assertions.assertThat;
@ -48,6 +51,8 @@ public class QueryScreenTest extends QBaseSeleniumTest
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json")
.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
@ -79,8 +84,8 @@ public class QueryScreenTest extends QBaseSeleniumTest
///////////////////////////////////////////////////////////////////
String idEquals1FilterSubstring = """
{"fieldName":"id","operator":"EQUALS","values":["1"]}""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/count", idEquals1FilterSubstring);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
///////////////////////////////////////
@ -99,8 +104,8 @@ public class QueryScreenTest extends QBaseSeleniumTest
////////////////////////////////////////////////////////////////////
// assert that query & count both no longer have the filter value //
////////////////////////////////////////////////////////////////////
CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count");
CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/count");
CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/query");
assertThat(capturedCount).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
assertThat(capturedQuery).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
@ -132,9 +137,9 @@ public class QueryScreenTest extends QBaseSeleniumTest
String expectedFilterContents2 = """
"booleanOperator":"OR\"""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents0);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents1);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents2);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectedFilterContents0);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectedFilterContents1);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectedFilterContents2);
qSeleniumJavalin.endCapture();
}
@ -200,6 +205,204 @@ public class QueryScreenTest extends QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "2", "3"));
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(3, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 3).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterInvalidValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "a", "3"));
//////////////////////////////////////////////////////
// check that chips match values and are classified //
//////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 1);
////////////////////////////////////////////////////////////////////
// confirm that an appropriate validation error message is shown //
////////////////////////////////////////////////////////////////////
WebElement errorMessage = qSeleniumLib.waitForSelectorContaining("span", "value is not a number");
assertThat(errorMessage.getText()).contains("value is not a number");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterDuplicateValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> pastedValues = List.of("1", "1", "1", "2", "2");
queryScreenLib.openCriteriaPasterAndPasteValues("id", pastedValues);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = pastedValues.size(); // 5
int uniqueCount = new HashSet<>(pastedValues).size(); // 2
/////////////////////////////
// chips should show dupes //
/////////////////////////////
assertFilterPasterChipCounts(pastedValues.size(), 0);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", List.of("St. Louis", "chesterfield"));
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 2).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSTwoGoodOneBadAndDupes()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> cities = List.of("St. Louis", "chesterfield", "Maryville", "st. louis", "st. louis", "chesterfield");
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", cities);
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = cities.size();
int uniqueCount = cities.stream().map(String::toLowerCase).collect(Collectors.toSet()).size();
///////////////////////////////////////////
// chips should show dupes and bad chips //
///////////////////////////////////////////
assertFilterPasterChipCounts(5, 1);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
//////////////////////////////////////////
// assert the "value not found" warning //
//////////////////////////////////////////
WebElement warning = qSeleniumLib.waitForSelectorContaining("span", "was not found");
assertThat(warning.getText()).contains("1 value was not found and will not be added to the filter");
}
/*******************************************************************************
**
*******************************************************************************/
@ -208,7 +411,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
qSeleniumJavalin.beginCapture();
queryScreenLib.setBasicBooleanFilter(fieldLabel, operatorLabel);
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
}
@ -222,7 +425,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
qSeleniumJavalin.beginCapture();
queryScreenLib.setBasicFilterPossibleValues(fieldLabel, operatorLabel, values);
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
}
@ -268,9 +471,23 @@ public class QueryScreenTest extends QBaseSeleniumTest
queryScreenLib.addAdvancedQueryFilterInput(0, fieldLabel, operatorLabel, value, null);
qSeleniumLib.clickBackdrop();
queryScreenLib.waitForAdvancedQueryStringMatchingRegex(expectQueryStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/qqq/v1/table/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
queryScreenLib.clickAdvancedFilterClearIcon();
}
/*******************************************************************************
**
*******************************************************************************/
private void assertFilterPasterChipCounts(int expectedValid, int expectedInvalid)
{
List<WebElement> chips = qSeleniumLib.waitForSelectorAll(".MuiChip-root", expectedValid + expectedInvalid);
long validCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorInfo")).count();
long errorCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorError")).count();
assertThat(validCount).isEqualTo(expectedValid);
assertThat(errorCount).isEqualTo(expectedInvalid);
}
}

View File

@ -58,6 +58,8 @@ public class SavedViewsTest extends QBaseSeleniumTest
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/data/person/count", "data/person/count.json");
qSeleniumJavalin.withRouteToFile("/data/person/query", "data/person/index.json");
qSeleniumJavalin.withRouteToFile("/qqq/v1/table/person/count", "qqq/v1/table/person/count.json");
qSeleniumJavalin.withRouteToFile("/qqq/v1/table/person/query", "qqq/v1/table/person/index.json");
qSeleniumJavalin.withRouteToFile("/data/person/*", "data/person/1701.json");
}
@ -135,7 +137,7 @@ public class SavedViewsTest extends QBaseSeleniumTest
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
queryScreenLib.assertSavedViewNameOnScreen("Some People");
qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes");
CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/query");
assertTrue(capturedContext.getBody().contains("Kelkhoff"));
qSeleniumJavalin.endCapture();
@ -162,7 +164,7 @@ public class SavedViewsTest extends QBaseSeleniumTest
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As");
capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
capturedContext = qSeleniumJavalin.waitForCapturedPath("/qqq/v1/table/person/query");
assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*"));
qSeleniumJavalin.endCapture();
}

View File

@ -0,0 +1,166 @@
{
"name": "person",
"label": "Person",
"isHidden": false,
"primaryKeyField": "id",
"iconName": "person",
"deletePermission": true,
"editPermission": true,
"insertPermission": true,
"readPermission": true,
"fields": {
"firstName": {
"name": "firstName",
"label": "First Name",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"displayFormat": "%s"
},
"lastName": {
"name": "lastName",
"label": "Last Name",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"displayFormat": "%s"
},
"annualSalary": {
"name": "annualSalary",
"label": "Annual Salary",
"type": "DECIMAL",
"isRequired": false,
"isEditable": true,
"displayFormat": "$%,.2f"
},
"modifyDate": {
"name": "modifyDate",
"label": "Modify Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
},
"daysWorked": {
"name": "daysWorked",
"label": "Days Worked",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"displayFormat": "%,d"
},
"id": {
"name": "id",
"label": "Id",
"type": "INTEGER",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
},
"birthDate": {
"name": "birthDate",
"label": "Birth Date",
"type": "DATE",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"isEmployed": {
"name": "isEmployed",
"label": "Is Employed",
"type": "BOOLEAN",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"homeCityId": {
"name": "homeCityId",
"label": "Home City",
"type": "INTEGER",
"possibleValueSourceName": "city",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"email": {
"name": "email",
"label": "Email",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"createDate": {
"name": "createDate",
"label": "Create Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
}
},
"sections": [
{
"name": "identity",
"label": "Identity",
"tier": "T1",
"fieldNames": [
"id",
"firstName",
"lastName"
],
"icon": {
"name": "badge"
},
"isHidden": false
},
{
"name": "basicInfo",
"label": "Basic Info",
"tier": "T2",
"fieldNames": [
"email",
"birthDate"
],
"icon": {
"name": "dataset"
},
"isHidden": false
},
{
"name": "employmentInfo",
"label": "Employment Info",
"tier": "T2",
"fieldNames": [
"isEmployed",
"annualSalary",
"daysWorked"
],
"icon": {
"name": "work"
},
"isHidden": false
},
{
"name": "dates",
"label": "Dates",
"tier": "T3",
"fieldNames": [
"createDate",
"modifyDate"
],
"icon": {
"name": "calendar_month"
},
"isHidden": false
}
],
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"TABLE_DELETE",
"TABLE_INSERT",
"TABLE_UPDATE"
]
}

View File

@ -0,0 +1,216 @@
{
"name": "savedReport",
"label": "Saved Report",
"isHidden": false,
"primaryKeyField": "id",
"iconName": "article",
"fields": {
"queryFilterJson": {
"name": "queryFilterJson",
"label": "Query Filter",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"columnsJson": {
"name": "columnsJson",
"label": "Columns",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"inputFieldsJson": {
"name": "inputFieldsJson",
"label": "Input Fields",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"pivotTableJson": {
"name": "pivotTableJson",
"label": "Pivot Table",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"modifyDate": {
"name": "modifyDate",
"label": "Modify Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"label": {
"name": "label",
"label": "Report Name",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"id": {
"name": "id",
"label": "Id",
"type": "INTEGER",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"userId": {
"name": "userId",
"label": "User Id",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"tableName": {
"name": "tableName",
"label": "Table",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"isHeavy": false,
"possibleValueSourceName": "tables",
"displayFormat": "%s"
},
"createDate": {
"name": "createDate",
"label": "Create Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
}
},
"sections": [
{
"name": "identity",
"label": "Identity",
"tier": "T1",
"fieldNames": [
"id",
"label",
"tableName"
],
"icon": {
"name": "badge"
},
"isHidden": false
},
{
"name": "filtersAndColumns",
"label": "Filters and Columns",
"tier": "T2",
"widgetName": "reportSetupWidget",
"icon": {
"name": "table_chart"
},
"isHidden": false
},
{
"name": "pivotTable",
"label": "Pivot Table",
"tier": "T2",
"widgetName": "pivotTableSetupWidget",
"icon": {
"name": "pivot_table_chart"
},
"isHidden": false
},
{
"name": "data",
"label": "Data",
"tier": "T2",
"fieldNames": [
"queryFilterJson",
"columnsJson",
"pivotTableJson"
],
"icon": {
"name": "text_snippet"
},
"isHidden": true
},
{
"name": "hidden",
"label": "Hidden",
"tier": "T2",
"fieldNames": [
"inputFieldsJson",
"userId"
],
"icon": {
"name": "text_snippet"
},
"isHidden": true
},
{
"name": "dates",
"label": "Dates",
"tier": "T3",
"fieldNames": [
"createDate",
"modifyDate"
],
"icon": {
"name": "calendar_month"
},
"isHidden": false
}
],
"exposedJoins": [],
"supplementalTableMetaData": {
"materialDashboard": {
"fieldRules": [
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "queryFilterJson"
},
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "columnsJson"
},
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "pivotTableJson"
}
],
"type": "materialDashboard"
}
},
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"QUERY_STATS",
"TABLE_UPDATE",
"TABLE_INSERT",
"TABLE_DELETE"
],
"readPermission": true,
"insertPermission": true,
"editPermission": true,
"deletePermission": true,
"usesVariants": false
}

View File

@ -0,0 +1,137 @@
{
"name": "script",
"label": "Script",
"isHidden": false,
"primaryKeyField": "id",
"iconName": "data_object",
"fields": {
"modifyDate": {
"name": "modifyDate",
"label": "Modify Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
},
"name": {
"name": "name",
"label": "Name",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"currentScriptRevisionId": {
"name": "currentScriptRevisionId",
"label": "Current Script Revision",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"possibleValueSourceName": "scriptRevision",
"displayFormat": "%s",
"adornments": [
{
"type": "LINK",
"values": {
"toRecordFromTable": "scriptRevision"
}
}
]
},
"id": {
"name": "id",
"label": "Id",
"type": "INTEGER",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
},
"tableName": {
"name": "tableName",
"label": "Table Name",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"possibleValueSourceName": "tables",
"displayFormat": "%s"
},
"createDate": {
"name": "createDate",
"label": "Create Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"displayFormat": "%s"
},
"scriptTypeId": {
"name": "scriptTypeId",
"label": "Script Type",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"possibleValueSourceName": "scriptType",
"displayFormat": "%s",
"adornments": [
{
"type": "LINK",
"values": {
"toRecordFromTable": "scriptType"
}
}
]
}
},
"sections": [
{
"name": "identity",
"label": "Identity",
"tier": "T1",
"fieldNames": [
"id",
"name",
"scriptTypeId",
"tableName",
"currentScriptRevisionId"
],
"icon": {
"name": "badge"
},
"isHidden": false
},
{
"name": "contents",
"label": "Contents",
"tier": "T2",
"widgetName": "scriptViewer",
"icon": {
"name": "data_object"
},
"isHidden": false
},
{
"name": "dates",
"label": "Dates",
"tier": "T3",
"fieldNames": [
"createDate",
"modifyDate"
],
"icon": {
"name": "calendar_month"
},
"isHidden": false
}
],
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"TABLE_INSERT",
"TABLE_DELETE",
"TABLE_UPDATE"
],
"readPermission": true,
"insertPermission": true,
"editPermission": true,
"deletePermission": true
}

View File

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

View File

@ -0,0 +1,3 @@
{
"records": []
}

View File

@ -0,0 +1,245 @@
{
"records": [
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 623577,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z",
"clientId": 107,
"auditDetail.id": 278660,
"auditDetail.auditId": 623577,
"auditDetail.message": "Set First Name to John",
"auditDetail.fieldName": "firstName",
"auditDetail.newValue": "John"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "623577",
"recordId": "1191682",
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 623577,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z",
"clientId": 107,
"auditDetail.id": 278661,
"auditDetail.auditId": 623577,
"auditDetail.message": "Removed Doe from Last Name",
"auditDetail.fieldName": "lastName",
"auditDetail.oldValue": "Doe"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "623577",
"recordId": "1191682",
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 623577,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z",
"clientId": 107,
"auditDetail.id": 278662,
"auditDetail.auditId": 623577,
"auditDetail.message": "Set Client to ACME",
"auditDetail.fieldName": "clientId",
"auditDetail.oldValue": "BetaMax",
"auditDetail.newValue": "ACME"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "623577",
"recordId": "1191682",
"message": "Record was Inserted",
"timestamp": "2023-02-17T14:11:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 624804,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Edited",
"timestamp": "2023-02-17T14:13:16Z",
"clientId": 107,
"auditDetail.id": 278990,
"auditDetail.auditId": 624804,
"auditDetail.message": "Set SLA Expected Service Days to 2",
"auditDetail.fieldName": "slaExpectedServiceDays",
"auditDetail.newValue": "2"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "624804",
"recordId": "1191682",
"message": "Record was Edited",
"timestamp": "2023-02-17T14:13:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 624804,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Edited",
"timestamp": "2023-02-17T14:13:16Z",
"clientId": 107,
"auditDetail.id": 278991,
"auditDetail.auditId": 624804,
"auditDetail.message": "Set SLA Status to \"Pending\"",
"auditDetail.fieldName": "slaStatusId",
"auditDetail.newValue": "Pending"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "624804",
"recordId": "1191682",
"message": "Record was Edited",
"timestamp": "2023-02-17T14:13:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 624809,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Audit message here",
"timestamp": "2023-02-17T14:13:16Z",
"clientId": 107,
"auditDetail.id": 279000,
"auditDetail.auditId": 624809,
"auditDetail.message": "This is a detail message"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "624809",
"recordId": "1191682",
"message": "Audit message here",
"timestamp": "2023-02-17T14:13:16Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 737694,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Edited",
"timestamp": "2023-02-17T17:22:08Z",
"clientId": 107,
"auditDetail.id": 299222,
"auditDetail.auditId": 737694,
"auditDetail.message": "Set Estimated Delivery Date Time to 2023-02-18 07:00:00 PM EST",
"auditDetail.fieldName": "estimatedDeliveryDateTime",
"auditDetail.newValue": "2023-02-18 07:00:00 PM EST"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "737694",
"recordId": "1191682",
"message": "Record was Edited",
"timestamp": "2023-02-17T17:22:08Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 737694,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Record was Edited",
"timestamp": "2023-02-17T17:22:08Z",
"clientId": 107,
"auditDetail.id": 299223,
"auditDetail.auditId": 737694,
"auditDetail.message": "Changed Parcel Tracking Status from \"Unknown\" to \"Pre Transit\"",
"auditDetail.fieldName": "parcelTrackingStatusId",
"auditDetail.oldValue": "Unknown",
"auditDetail.newValue": "Pre Transit"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "737694",
"recordId": "1191682",
"message": "Record was Edited",
"timestamp": "2023-02-17T17:22:08Z"
}
},
{
"tableName": "audit",
"recordLabel": "Parcel 1191682",
"values": {
"id": 737695,
"auditTableId": 4,
"auditUserId": 2,
"recordId": 1191682,
"message": "Updating Parcel based on updated tracking details",
"timestamp": "2023-02-17T17:22:09Z",
"clientId": 107,
"auditDetail.id": 299224,
"auditDetail.auditId": 737695,
"auditDetail.message": "Set Parcel Tracking Status to Pre Transit based on most recent tracking update: Shipment information sent to FedEx"
},
"displayValues": {
"auditTableId": "Parcel",
"auditUserId": "QQQ User",
"clientId": "ACME",
"id": "737695",
"recordId": "1191682",
"message": "Updating Parcel based on updated tracking details",
"timestamp": "2023-02-17T17:22:09Z"
}
}
]
}

View File

@ -0,0 +1,3 @@
{
"count": 101406
}

View File

@ -0,0 +1,16 @@
{
"tableName": "person",
"recordLabel": "John Doe",
"values": {
"name": "John Doe",
"id": 1710,
"createDate": "2022-08-30T00:31:00Z",
"modifyDate": "2022-08-30T00:31:00Z"
},
"displayValues": {
"name": "John Doe",
"id": 1710,
"createDate": "2022-08-30T00:31:00Z",
"modifyDate": "2022-08-30T00:31:00Z"
}
}

View File

@ -0,0 +1,3 @@
{
"count": 101406
}

View File

@ -0,0 +1,276 @@
{
"record": {
"tableName": "client",
"recordLabel": "John Doe",
"values": {
"name": "John Doe",
"id": 120,
"deposcoOrderOptimizationCoolingScriptId": 2,
"createDate": "2022-08-30T00:31:00Z",
"modifyDate": "2023-02-19T01:28:30Z",
"isFulfillmentCenter": false,
"infoplusLobId": 18698,
"deposcoBusinessUnitName": "TRIFECTA",
"deposcoBusinessUnitId": 77,
"optimizationConfigId": 1,
"nfCode": "Client 224"
},
"displayValues": {
"optimizationConfigId": "Client: 120",
"name": "John Doe",
"id": "120",
"deposcoOrderOptimizationCoolingScriptId": "2",
"createDate": "2022-08-30T00:31:00Z",
"modifyDate": "2023-02-19T01:28:30Z",
"isFulfillmentCenter": "No",
"infoplusLobId": "18698",
"deposcoBusinessUnitName": "TRIFECTA",
"deposcoBusinessUnitId": "77",
"nfCode": "Client 224"
}
},
"associatedScripts": [
{
"testInputFields": [
{
"name": "selectedTimeInTransitDays",
"label": "Selected Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "standardTimeInTransitDays",
"label": "Standard Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"scriptType": {
"tableName": "scriptType",
"values": {
"name": "Deposco Order Optimization Cooling",
"id": 2,
"createDate": "2022-10-31T19:06:50Z",
"modifyDate": "2022-10-31T19:06:50Z"
}
},
"scriptRevisions": [
{
"tableName": "scriptRevision",
"values": {
"id": 1,
"contents": "1;",
"createDate": "2023-02-19T01:28:30Z",
"modifyDate": "2023-02-19T01:28:30Z",
"scriptId": 2,
"sequenceNo": 1,
"commitMessage": "Initial version",
"author": "Darin Kelkhoff"
}
}
],
"testOutputFields": [
{
"name": "sku",
"label": "Sku",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "quantityPerCarton",
"label": "Quantity Per Carton",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "useClientProvidedCoolingSolution",
"label": "Use Client Provided Cooling Solution",
"type": "BOOLEAN",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "reason",
"label": "Reason",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"script": {
"tableName": "script",
"values": {
"name": "John Doe - Deposco Order Optimization Cooling",
"id": 2,
"scriptTypeId": 2,
"createDate": "2023-02-19T01:28:30Z",
"modifyDate": "2023-02-19T01:28:30Z",
"currentScriptRevisionId": 1
}
},
"associatedScript": {
"fieldName": "deposcoOrderOptimizationCoolingScriptId",
"scriptTypeId": 2,
"scriptTester": {
"name": "com.coldtrack.live.processes.deposco.RunDeposcoOrderOptimizationCoolingScript",
"codeType": "JAVA",
"codeUsage": "SCRIPT_TESTER"
}
}
},
{
"testInputFields": [
{
"name": "selectedTimeInTransitDays",
"label": "Selected Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "standardTimeInTransitDays",
"label": "Standard Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "runtimeWeekday",
"label": "Runtime Weekday",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"scriptType": {
"tableName": "scriptType",
"values": {
"name": "Deposco Order Optimization Batch Name",
"id": 1,
"createDate": "2022-10-31T19:06:50Z",
"modifyDate": "2022-10-31T19:06:50Z"
}
},
"testOutputFields": [
{
"name": "batchName",
"label": "Batch Name",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "reason",
"label": "Reason",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"associatedScript": {
"fieldName": "deposcoOrderOptimizationBatchNameScriptId",
"scriptTypeId": 1,
"scriptTester": {
"name": "com.coldtrack.live.processes.deposco.RunDeposcoOrderOptimizationBatchNameScript",
"codeType": "JAVA",
"codeUsage": "SCRIPT_TESTER"
}
}
},
{
"testInputFields": [
{
"name": "selectedTimeInTransitDays",
"label": "Selected Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "standardTimeInTransitDays",
"label": "Standard Time In Transit Days",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "runtimeWeekday",
"label": "Runtime Weekday",
"type": "INTEGER",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"scriptType": {
"tableName": "scriptType",
"values": {
"name": "Deposco Order Optimization Batch Name",
"id": 1,
"createDate": "2022-10-31T19:06:50Z",
"modifyDate": "2022-10-31T19:06:50Z"
}
},
"testOutputFields": [
{
"name": "batchName",
"label": "Batch Name",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
{
"name": "reason",
"label": "Reason",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
}
],
"associatedScript": {
"fieldName": "deposcoOrderOptimizationCartonizationScriptId",
"scriptTypeId": 1,
"scriptTester": {
"name": "com.coldtrack.live.processes.deposco.RunDeposcoOrderOptimizationBatchNameScript",
"codeType": "JAVA",
"codeUsage": "SCRIPT_TESTER"
}
}
}
]
}

View File

@ -0,0 +1,64 @@
{
"records": [
{
"tableName": "person",
"values": {
"id": 1,
"createDate": "2022-07-23T00:17:00",
"modifyDate": "2022-07-22T19:17:06",
"firstName": "Jonny",
"lastName": "Doe",
"birthDate": "1980-05-31",
"email": "jdoe@kingsrook.com"
}
},
{
"tableName": "person",
"values": {
"id": 2,
"createDate": "2022-07-23T00:17:00",
"modifyDate": "2022-07-23T00:17:00",
"firstName": "James",
"lastName": "Maes",
"birthDate": "1980-05-15",
"email": "jmaes@mmltholdings.com"
}
},
{
"tableName": "person",
"values": {
"id": 3,
"createDate": "2022-07-23T00:17:00",
"modifyDate": "2022-07-23T00:17:00",
"firstName": "Tim",
"lastName": "Chamberlain",
"birthDate": "1976-05-28",
"email": "tchamberlain@mmltholdings.com"
}
},
{
"tableName": "person",
"values": {
"id": 4,
"createDate": "2022-07-23T00:17:00",
"modifyDate": "2022-07-23T00:17:00",
"firstName": "Tyler",
"lastName": "Samples",
"birthDate": "1986-05-28",
"email": "tsamples@mmltholdings.com"
}
},
{
"tableName": "person",
"values": {
"id": 5,
"createDate": "2022-07-23T00:17:00",
"modifyDate": "2022-07-23T00:17:00",
"firstName": "Garret",
"lastName": "Richardson",
"birthDate": "1981-01-01",
"email": "grichardson@mmltholdings.com"
}
}
]
}

View File

@ -0,0 +1,12 @@
{
"options": [
{
"id": 1,
"label": "St. Louis"
},
{
"id": 2,
"label": "Chesterfield"
}
]
}

View File

@ -0,0 +1,8 @@
{
"options": [
{
"id": 1,
"label": "St. Louis"
}
]
}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,16 @@
{
"options": [
{
"id": "person",
"label": "Person"
},
{
"id": "city",
"label": "City"
},
{
"id": "savedReport",
"label": "Saved Report"
}
]
}

View File

@ -0,0 +1,22 @@
{
"tableName": "script",
"recordLabel": "Hello, Script",
"values": {
"name": "Hello, Script",
"id": 1,
"currentScriptRevisionId": 100,
"tableName": "client",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z",
"scriptTypeId": 1
},
"displayValues": {
"tableName": "Client",
"scriptTypeId": "Record Script",
"name": "Hello, Script",
"currentScriptRevisionId": 100,
"id": "1",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
}
}

View File

@ -0,0 +1,3 @@
{
"records": []
}

View File

@ -0,0 +1,36 @@
{
"tableName": "scriptRevision",
"recordLabel": "Hello, Script Revision",
"values": {
"id": "100",
"name": "Hello, Script Revision",
"sequenceNo": "22",
"commitMessage": "Initial checkin",
"author": "Jon Programmer",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
},
"displayValues": {
"id": "1",
"name": "Hello, Script Revision",
"scriptId": "1",
"sequenceNo": "22",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
},
"associatedRecords": {
"files": [
{
"tableName": "scriptRevisionFile",
"values": {
"id": 101,
"fileName": "Script.js",
"contents": "var hello;",
"scriptRevisionId": 100,
"createDate": "2023-06-23T21:59:57Z",
"modifyDate": "2023-06-23T21:59:57Z"
}
}
]
}
}

View File

@ -0,0 +1,32 @@
{
"records": [
{
"tableName": "scriptRevision",
"values": {
"contents": "var hello;",
"id": 100,
"sequenceNo": 2,
"commitMessage": "2nd commit",
"author": "Jon Programmer",
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z"
},
"displayValues": {
}
},
{
"tableName": "scriptRevision",
"values": {
"contents": "var goodBye;",
"id": 99,
"sequenceNo": 1,
"commitMessage": "Initial checkin",
"author": "Jane Programmer",
"createDate": "2023-02-17T00:47:51Z",
"modifyDate": "2023-02-17T00:47:51Z"
},
"displayValues": {
}
}
]
}

View File

@ -0,0 +1,13 @@
{
"tableName": "scriptType",
"recordLabel": "Record Script",
"values": {
"name": "Record Script",
"id": 1,
"createDate": "2023-02-18T00:47:51Z",
"modifyDate": "2023-02-18T00:47:51Z",
"fileMode": 1
},
"displayValues": {
}
}