Compare commits

..

6 Commits

17 changed files with 373 additions and 136 deletions

View File

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

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

14
pom.xml
View File

@ -161,6 +161,20 @@
<skipUpdateVersion>true</skipUpdateVersion>
</configuration>
</plugin>
<!-- Publish this project's test code as a jar -->
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@ -33,7 +33,7 @@ import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import React, {JSXElementConstructor, Key, ReactElement, useContext, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
@ -57,11 +57,11 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
const qController = Client.getInstance();
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export const SESSION_ID_COOKIE_NAME = "sessionId";
export default function App()
{
const [, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [, setCookie, removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0();
const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
@ -69,67 +69,8 @@ export default function App()
const [branding, setBranding] = useState({} as QBrandingMetaData);
const [metaData, setMetaData] = useState({} as QInstance);
const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const decodeJWT = (jwt: string): any =>
{
const base64Url = jwt.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(window.atob(base64).split("").map(function (c)
{
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
return JSON.parse(jsonPayload);
};
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!oldToken)
{
return (true);
}
try
{
const oldJSON = decodeJWT(oldToken);
const newJSON = decodeJWT(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.");
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.");
}
return (different);
}
catch(e)
{
console.log("Caught in shouldStoreNewToken: " + e)
}
return (true);
};
useEffect(() =>
{
if (loadingToken)
@ -151,38 +92,20 @@ export default function App()
{
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 newSessionUuid = await qController.manageSession(accessToken, null);
setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
}
/*
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - this is our quick rollback plan - if we feel the need to stop using the cookie approach. //
// we turn off the shouldStoreNewToken block above, and turn on these 2 lines. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
localStorage.removeItem("accessToken");
*/
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user);
/////////////////////////////////////////////////////////////////////////////////
// we've stopped using session id cook with auth0, so make sure it is not set. //
/////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_ID_COOKIE_NAME);
setIsFullyAuthenticated(true);
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;
}
@ -193,9 +116,9 @@ export default function App()
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`));
qController.setAuthorizationHeaderValue(null);
setIsFullyAuthenticated(true);
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
setCookie(SESSION_ID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
}
@ -226,7 +149,7 @@ export default function App()
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]);
const [appRoutes, setAppRoutes] = useState(null as any);
const [pathToLabelMap, setPathToLabelMap] = useState({} as { [path: string]: string });
const [pathToLabelMap, setPathToLabelMap] = useState({} as {[path: string]: string});
////////////////////////////////////////////
// load qqq meta data to make more routes //
@ -344,14 +267,14 @@ export default function App()
name: `${app.label}`,
key: app.name,
route: path,
component: <RecordQuery table={table} key={table.name} />,
component: <RecordQuery table={table} key={table.name}/>,
});
routeList.push({
name: `${app.label}`,
key: app.name,
route: `${path}/savedFilter/:id`,
component: <RecordQuery table={table} key={table.name} />,
component: <RecordQuery table={table} key={table.name}/>,
});
routeList.push({
@ -506,11 +429,11 @@ export default function App()
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(loggedInUser?.email || "user");
const hash = Md5.hashStr(user?.email || "user");
const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = {
type: "collapse",
name: loggedInUser?.name ?? "Anonymous",
name: user?.name,
key: "username",
noCollapse: true,
icon: <Avatar src={profilePicture} alt="{user?.name}" />,
@ -546,7 +469,7 @@ export default function App()
}
const pathToLabelMap: {[path: string]: string} = {}
for (let i = 0; i < appRoutesList.length; i++)
for(let i =0; i<appRoutesList.length; i++)
{
const route = appRoutesList[i];
pathToLabelMap[route.route] = route.name;
@ -572,10 +495,7 @@ export default function App()
{
if ((e as QException).status === "401")
{
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
//////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic //
@ -676,7 +596,7 @@ export default function App()
}}>
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData} />
<CommandMenu metaData={metaData}/>
<Sidenav
color={sidenavColor}
icon={branding.icon}

View File

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

View File

@ -221,19 +221,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const download = (url: string, fileName: string) =>
{
/////////////////////////////////////////////////////////////////////////////////////////////
// todo - this could be simplified. //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const qController = Client.getInstance();
let xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.responseType = "blob";
let formData = new FormData();
////////////////////////////////////
// todo#authHeader - delete this. //
////////////////////////////////////
const qController = Client.getInstance();
formData.append("Authorization", qController.getAuthorizationHeaderValue());
// @ts-ignore

View File

@ -1139,7 +1139,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" >
<!-- todo#authHeader - remove this. -->
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">

View File

@ -193,7 +193,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{
document.removeEventListener("keydown", down)
}
}, [dotMenuOpen, keyboardHelpOpen, showEditChildForm, showAudit, metaData])
}, [dotMenuOpen, keyboardHelpOpen, showEditChildForm, showAudit, metaData, location])
const gotoCreate = () =>
{

View File

@ -95,11 +95,6 @@ export default class HtmlUtils
form.setAttribute("target", "downloadIframe");
iframe.appendChild(form);
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const authorizationInput = document.createElement("input");
authorizationInput.setAttribute("type", "hidden");
authorizationInput.setAttribute("id", "authorizationInput");
@ -123,11 +118,6 @@ export default class HtmlUtils
{
if(url.startsWith("data:"))
{
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove the Authorization input after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const openInWindow = window.open("", "_blank");
openInWindow.document.write(`<html lang="en">
<body style="margin: 0">

View File

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

View File

@ -18,7 +18,7 @@ import org.openqa.selenium.chrome.ChromeOptions;
*******************************************************************************/
public class QBaseSeleniumTest
{
private static ChromeOptions chromeOptions;
protected static ChromeOptions chromeOptions;
protected WebDriver driver;
protected QSeleniumJavalin qSeleniumJavalin;
@ -52,15 +52,29 @@ public class QBaseSeleniumTest
**
*******************************************************************************/
@BeforeEach
void beforeEach()
public void beforeEach()
{
driver = new ChromeDriver(chromeOptions);
driver.manage().window().setSize(new Dimension(1700, 1300));
qSeleniumLib = new QSeleniumLib(driver);
qSeleniumJavalin = new QSeleniumJavalin();
addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.start();
if(useInternalJavalin())
{
qSeleniumJavalin = new QSeleniumJavalin();
addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.start();
}
}
/*******************************************************************************
** control if the test needs to start its own javalin server, or if we're running
** in an environment where an external web server is being used.
*******************************************************************************/
protected boolean useInternalJavalin()
{
return (true);
}
@ -75,6 +89,8 @@ public class QBaseSeleniumTest
.withRouteToFile("/metaData/authentication", "metaData/authentication.json")
.withRouteToFile("/metaData/table/person", "metaData/table/person.json")
.withRouteToFile("/metaData/table/city", "metaData/table/person.json")
.withRouteToFile("/metaData/table/script", "metaData/table/script.json")
.withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
}

View File

@ -5,6 +5,7 @@ import java.io.File;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@ -96,6 +97,17 @@ public class QSeleniumLib
/*******************************************************************************
** Getter for BASE_URL
**
*******************************************************************************/
public String getBaseUrl()
{
return BASE_URL;
}
/*******************************************************************************
**
*******************************************************************************/
@ -265,6 +277,31 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
public void waitForNumberOfWindowsToBe(int number)
{
LOG.debug("Waiting for number of windows (tabs) to be [" + number + "]");
long start = System.currentTimeMillis();
do
{
if(driver.getWindowHandles().size() == number)
{
LOG.debug("Number of windows (tabs) is [" + number + "]");
return;
}
sleepABit();
}
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
fail("Failed waiting for number of windows (tabs) to be [" + number + "] after [" + WAIT_SECONDS + "] seconds.");
}
/*******************************************************************************
**
*******************************************************************************/
@ -293,6 +330,53 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
public void switchToSecondaryTab()
{
String originalWindow = driver.getWindowHandle();
waitForNumberOfWindowsToBe(2);
Set<String> windowHandles = driver.getWindowHandles();
for(String windowHandle : windowHandles)
{
if(!windowHandle.equals(originalWindow))
{
driver.switchTo().window(windowHandle);
return;
}
}
fail("Failed to find a window handle not equal to the original window handle. Original=[" + originalWindow + "]. All=[" + windowHandles + "]");
}
/*******************************************************************************
**
*******************************************************************************/
public void closeSecondaryTab()
{
String originalWindow = driver.getWindowHandle();
driver.close();
Set<String> windowHandles = driver.getWindowHandles();
for(String windowHandle : windowHandles)
{
if(!windowHandle.equals(originalWindow))
{
driver.switchTo().window(windowHandle);
return;
}
}
fail("Failed to find a window handle not equal to the original window handle. Original=[" + originalWindow + "]. All=[" + windowHandles + "]");
}
@FunctionalInterface
public interface Code<T>
{

View File

@ -0,0 +1,63 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.materialdashboard.tests;
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Test for Associated Record Scripts functionality.
*******************************************************************************/
public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/data/script/1", "data/script/1.json");
qSeleniumJavalin.withRouteToFile("/data/scriptRevision/100", "data/scriptRevision/100.json");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testClickLinkOnRecordThenEditShortcutTest()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script");
qSeleniumLib.waitForSelectorContaining("A", "100").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").sendKeys("e");
assertTrue(qSeleniumLib.driver.getCurrentUrl().endsWith("/scriptRevision/100/edit"));
}
}

View File

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

View File

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

View File

@ -131,7 +131,8 @@
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY"
"TABLE_QUERY",
"TABLE_UPDATE"
],
"readPermission": true,
"insertPermission": true,

View File

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