mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-22 07:08:44 +00:00
Compare commits
23 Commits
snapshot-f
...
feature/18
Author | SHA1 | Date | |
---|---|---|---|
f5a3b9eb42
|
|||
461855dc3c
|
|||
1fd4780ea4
|
|||
ad0b9698b1
|
|||
d41f5f8339 | |||
4d30eb3060 | |||
d4a675e952 | |||
633c97b710 | |||
c70ef3dae8 | |||
5c69ae666c | |||
2e5aba6c16 | |||
185775ca4d | |||
cbcb3b505e | |||
ce91f68088 | |||
81da1a4627 | |||
b279a04b43 | |||
1f2e57d688 | |||
52bb7ba411 | |||
d792c23035 | |||
e3d30633f1 | |||
debc6f3ebf | |||
f654208769 | |||
3dacab8d60 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,3 +31,4 @@ yalc.lock
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
/src/main/resources/material-dashboard/
|
||||||
|
@ -154,7 +154,7 @@ material-dashboard-2-pro-react-ts
|
|||||||
│ │ ├── Cards
|
│ │ ├── Cards
|
||||||
│ │ ├── Charts
|
│ │ ├── Charts
|
||||||
│ │ ├── Configurator
|
│ │ ├── Configurator
|
||||||
│ │ ├── Footer
|
│ │ ├── FooterCard
|
||||||
│ │ ├── Items
|
│ │ ├── Items
|
||||||
│ │ ├── LayoutContainers
|
│ │ ├── LayoutContainers
|
||||||
│ │ ├── Lists
|
│ │ ├── Lists
|
||||||
|
23732
package-lock.json
generated
23732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -6,7 +6,7 @@
|
|||||||
"@auth0/auth0-react": "1.10.2",
|
"@auth0/auth0-react": "1.10.2",
|
||||||
"@emotion/react": "11.7.1",
|
"@emotion/react": "11.7.1",
|
||||||
"@emotion/styled": "11.6.0",
|
"@emotion/styled": "11.6.0",
|
||||||
"@kingsrook/qqq-frontend-core": "1.0.119",
|
"@kingsrook/qqq-frontend-core": "1.0.122",
|
||||||
"@mui/icons-material": "5.4.1",
|
"@mui/icons-material": "5.4.1",
|
||||||
"@mui/material": "5.11.1",
|
"@mui/material": "5.11.1",
|
||||||
"@mui/styles": "5.11.1",
|
"@mui/styles": "5.11.1",
|
||||||
@ -23,6 +23,8 @@
|
|||||||
"@types/react-dom": "18.0.0",
|
"@types/react-dom": "18.0.0",
|
||||||
"@types/react-router-hash-link": "2.4.5",
|
"@types/react-router-hash-link": "2.4.5",
|
||||||
"ace-builds": "1.12.3",
|
"ace-builds": "1.12.3",
|
||||||
|
"ajv": "^8.11.0",
|
||||||
|
"ajv-keywords": "^5.1.0",
|
||||||
"chart.js": "3.4.1",
|
"chart.js": "3.4.1",
|
||||||
"chroma-js": "2.4.2",
|
"chroma-js": "2.4.2",
|
||||||
"cmdk": "0.2.0",
|
"cmdk": "0.2.0",
|
||||||
@ -36,6 +38,8 @@
|
|||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"http-proxy-middleware": "2.0.6",
|
"http-proxy-middleware": "2.0.6",
|
||||||
"jwt-decode": "3.1.2",
|
"jwt-decode": "3.1.2",
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"oidc-client-ts": "2.4.1",
|
||||||
"rapidoc": "9.3.4",
|
"rapidoc": "9.3.4",
|
||||||
"react": "18.0.0",
|
"react": "18.0.0",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
@ -49,6 +53,7 @@
|
|||||||
"react-github-btn": "1.2.1",
|
"react-github-btn": "1.2.1",
|
||||||
"react-google-drive-picker": "^1.2.0",
|
"react-google-drive-picker": "^1.2.0",
|
||||||
"react-markdown": "9.0.1",
|
"react-markdown": "9.0.1",
|
||||||
|
"react-oidc-context": "2.3.1",
|
||||||
"react-router-dom": "6.2.1",
|
"react-router-dom": "6.2.1",
|
||||||
"react-router-hash-link": "2.4.3",
|
"react-router-hash-link": "2.4.3",
|
||||||
"react-table": "7.7.0",
|
"react-table": "7.7.0",
|
||||||
@ -57,14 +62,15 @@
|
|||||||
"yup": "0.32.11"
|
"yup": "0.32.11"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "react-scripts build",
|
"build": "PUBLIC_URL=. react-scripts build",
|
||||||
"clean": "rm -rf node_modules package-lock.json lib",
|
"clean": "rm -rf node_modules package-lock.json lib",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
|
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
|
||||||
"npm-install": "npm install --legacy-peer-deps",
|
"npm-install": "npm install --legacy-peer-deps",
|
||||||
"prepublishOnly": "tsc -p ./ --outDir lib/",
|
"prepublishOnly": "tsc -p ./ --outDir lib/",
|
||||||
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",
|
"start": "PUBLIC_URL=. BROWSER=none react-scripts --max-http-header-size=65535 start",
|
||||||
"test": "react-scripts test"
|
"test": "react-scripts test",
|
||||||
|
"export": "rm -rf dist && PUBLIC_URL=. react-scripts build && rm -rf src/main/resources/material-dashboard && mkdir -p src/main/resources/material-dashboard && cp -r build/* src/main/resources/material-dashboard"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
4
pom.xml
4
pom.xml
@ -29,7 +29,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.25.0-SNAPSHOT</revision>
|
<revision>0.26.0-SNAPSHOT</revision>
|
||||||
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
@ -66,7 +66,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.kingsrook.qqq</groupId>
|
<groupId>com.kingsrook.qqq</groupId>
|
||||||
<artifactId>qqq-backend-core</artifactId>
|
<artifactId>qqq-backend-core</artifactId>
|
||||||
<version>0.25.0-integration-sprint-62-20250307-205536</version>
|
<version>0.26.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
|
208
src/App.tsx
208
src/App.tsx
@ -19,7 +19,6 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useAuth0} from "@auth0/auth0-react";
|
|
||||||
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||||
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||||
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||||
@ -35,12 +34,14 @@ import Icon from "@mui/material/Icon";
|
|||||||
import {ThemeProvider} from "@mui/material/styles";
|
import {ThemeProvider} from "@mui/material/styles";
|
||||||
import {LicenseInfo} from "@mui/x-license-pro";
|
import {LicenseInfo} from "@mui/x-license-pro";
|
||||||
import CommandMenu from "CommandMenu";
|
import CommandMenu from "CommandMenu";
|
||||||
import jwt_decode from "jwt-decode";
|
|
||||||
import QContext from "QContext";
|
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 Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
|
||||||
import theme from "qqq/components/legacy/Theme";
|
import theme from "qqq/components/legacy/Theme";
|
||||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||||
import {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context";
|
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
|
||||||
import AppHome from "qqq/pages/apps/Home";
|
import AppHome from "qqq/pages/apps/Home";
|
||||||
import NoApps from "qqq/pages/apps/NoApps";
|
import NoApps from "qqq/pages/apps/NoApps";
|
||||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||||
@ -64,10 +65,14 @@ import {Md5} from "ts-md5/dist/md5";
|
|||||||
const qController = Client.getInstance();
|
const qController = Client.getInstance();
|
||||||
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
|
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
|
||||||
|
|
||||||
export default function App()
|
interface Props
|
||||||
{
|
{
|
||||||
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
authenticationMetaData: QAuthenticationMetaData;
|
||||||
const {user, getAccessTokenSilently, logout} = useAuth0();
|
}
|
||||||
|
|
||||||
|
export default function App({authenticationMetaData}: Props)
|
||||||
|
{
|
||||||
|
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||||
const [loadingToken, setLoadingToken] = useState(false);
|
const [loadingToken, setLoadingToken] = useState(false);
|
||||||
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
||||||
const [profileRoutes, setProfileRoutes] = useState({});
|
const [profileRoutes, setProfileRoutes] = useState({});
|
||||||
@ -76,68 +81,20 @@ export default function App()
|
|||||||
const [needLicenseKey, setNeedLicenseKey] = useState(true);
|
const [needLicenseKey, setNeedLicenseKey] = useState(true);
|
||||||
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
|
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
|
||||||
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
|
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 //
|
// tell the client how to do a logout if it sees a 401 //
|
||||||
/////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////
|
||||||
Client.setUnauthorizedCallback(() =>
|
Client.setUnauthorizedCallback(() => doLogout());
|
||||||
{
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////
|
||||||
|
// deal with making sure user is authenticated //
|
||||||
|
/////////////////////////////////////////////////
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if (loadingToken)
|
if (loadingToken)
|
||||||
@ -148,65 +105,17 @@ export default function App()
|
|||||||
|
|
||||||
(async () =>
|
(async () =>
|
||||||
{
|
{
|
||||||
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
|
|
||||||
|
|
||||||
if (authenticationMetaData.type === "AUTH_0")
|
if (authenticationMetaData.type === "AUTH_0")
|
||||||
{
|
{
|
||||||
/////////////////////////////////////////
|
await auth0SetupSession();
|
||||||
// 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
|
else if (authenticationMetaData.type === "OAUTH2")
|
||||||
{
|
{
|
||||||
console.log("Using existing sessionUUID cookie");
|
await oauth2SetupSession();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
||||||
{
|
{
|
||||||
/////////////////////////////////////////////
|
await anonymousSetupSession();
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -222,13 +131,36 @@ export default function App()
|
|||||||
(async () =>
|
(async () =>
|
||||||
{
|
{
|
||||||
const metaData: QInstance = await qController.loadMetaData();
|
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);
|
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 [controller, dispatch] = useMaterialUIController();
|
||||||
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
|
const {miniSidenav, direction, sidenavColor} = controller;
|
||||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||||
const {pathname} = useLocation();
|
const {pathname} = useLocation();
|
||||||
const [queryParams] = useSearchParams();
|
const [queryParams] = useSearchParams();
|
||||||
@ -521,11 +453,10 @@ export default function App()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let profileRoutes = {};
|
|
||||||
const gravatarBase = "https://www.gravatar.com/avatar/";
|
const gravatarBase = "https://www.gravatar.com/avatar/";
|
||||||
const hash = Md5.hashStr(loggedInUser?.email || "user");
|
const hash = Md5.hashStr(loggedInUser?.email || "user");
|
||||||
const profilePicture = `${gravatarBase}${hash}`;
|
const profilePicture = `${gravatarBase}${hash}`;
|
||||||
profileRoutes = {
|
const profileRoutes = {
|
||||||
type: "collapse",
|
type: "collapse",
|
||||||
name: loggedInUser?.name ?? "Anonymous",
|
name: loggedInUser?.name ?? "Anonymous",
|
||||||
key: "username",
|
key: "username",
|
||||||
@ -594,10 +525,7 @@ export default function App()
|
|||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||||
|
|
||||||
//////////////////////////////////////////////////////
|
doLogout();
|
||||||
// todo - this is auth0 logout... make more generic //
|
|
||||||
//////////////////////////////////////////////////////
|
|
||||||
logout();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -605,7 +533,9 @@ export default function App()
|
|||||||
})();
|
})();
|
||||||
}, [needToLoadRoutes, isFullyAuthenticated]);
|
}, [needToLoadRoutes, isFullyAuthenticated]);
|
||||||
|
|
||||||
// Open sidenav when mouse enter on mini sidenav
|
///////////////////////////////////////////////////
|
||||||
|
// Open sidenav when mouse enter on mini sidenav //
|
||||||
|
///////////////////////////////////////////////////
|
||||||
const handleOnMouseEnter = () =>
|
const handleOnMouseEnter = () =>
|
||||||
{
|
{
|
||||||
if (miniSidenav && !onMouseEnter)
|
if (miniSidenav && !onMouseEnter)
|
||||||
@ -615,7 +545,9 @@ export default function App()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close sidenav when mouse leave mini sidenav
|
/////////////////////////////////////////////////
|
||||||
|
// Close sidenav when mouse leave mini sidenav //
|
||||||
|
/////////////////////////////////////////////////
|
||||||
const handleOnMouseLeave = () =>
|
const handleOnMouseLeave = () =>
|
||||||
{
|
{
|
||||||
if (onMouseEnter)
|
if (onMouseEnter)
|
||||||
@ -625,16 +557,14 @@ export default function App()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Change the openConfigurator state
|
|
||||||
const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator);
|
|
||||||
|
|
||||||
// Setting the dir attribute for the body element
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
document.body.setAttribute("dir", direction);
|
document.body.setAttribute("dir", direction);
|
||||||
}, [direction]);
|
}, [direction]);
|
||||||
|
|
||||||
// Setting page scroll to 0 when changing the route
|
//////////////////////////////////////////////////////
|
||||||
|
// Setting page scroll to 0 when changing the route //
|
||||||
|
//////////////////////////////////////////////////////
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
document.documentElement.scrollTop = 0;
|
document.documentElement.scrollTop = 0;
|
||||||
@ -674,12 +604,12 @@ export default function App()
|
|||||||
const [dotMenuOpen, setDotMenuOpen] = useState(false);
|
const [dotMenuOpen, setDotMenuOpen] = useState(false);
|
||||||
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
|
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
|
||||||
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
|
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
|
||||||
const [userId, setUserId] = useState(user?.email);
|
const [userId, setUserId] = useState(loggedInUser?.email);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
setUserId(user?.email)
|
setUserId(loggedInUser?.email);
|
||||||
}, [user]);
|
}, [loggedInUser]);
|
||||||
|
|
||||||
|
|
||||||
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
|
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
|
||||||
@ -689,7 +619,16 @@ export default function App()
|
|||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
function recordAnalytics(model: AnalyticsModel)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -747,6 +686,7 @@ export default function App()
|
|||||||
routes={sideNavRoutes}
|
routes={sideNavRoutes}
|
||||||
onMouseEnter={handleOnMouseEnter}
|
onMouseEnter={handleOnMouseEnter}
|
||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
|
logout={doLogout}
|
||||||
/>
|
/>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="*" element={<Navigate to={defaultRoute} />} />
|
<Route path="*" element={<Navigate to={defaultRoute} />} />
|
||||||
|
149
src/index.tsx
149
src/index.tsx
@ -19,116 +19,111 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Auth0Provider} from "@auth0/auth0-react";
|
|
||||||
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||||
import React from "react";
|
|
||||||
import {createRoot} from "react-dom/client";
|
|
||||||
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
|
|
||||||
import App from "App";
|
import App from "App";
|
||||||
import "qqq/styles/qqq-override-styles.css";
|
import "qqq/styles/qqq-override-styles.css";
|
||||||
import "qqq/styles/globals.scss";
|
import "qqq/styles/globals.scss";
|
||||||
import "qqq/styles/raycast.scss";
|
import "qqq/styles/raycast.scss";
|
||||||
import HandleAuthorizationError from "HandleAuthorizationError";
|
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
|
||||||
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
|
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
|
||||||
|
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
|
||||||
import {MaterialUIControllerProvider} from "qqq/context";
|
import {MaterialUIControllerProvider} from "qqq/context";
|
||||||
import Client from "qqq/utils/qqq/Client";
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import React from "react";
|
||||||
|
import {createRoot} from "react-dom/client";
|
||||||
|
import {BrowserRouter} from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
const qController = Client.getInstance();
|
const qController = Client.getInstance();
|
||||||
|
|
||||||
if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
|
function getBasePath(): string
|
||||||
{
|
{
|
||||||
qController.clearAuthenticationMetaDataLocalStorage()
|
// You can change this logic depending on how you detect your mount point
|
||||||
|
const path = window.location.pathname;
|
||||||
|
|
||||||
|
console.warn("Using hacked base path for QQQ application, please update this code to be better : path ["+ path +"].");
|
||||||
|
|
||||||
|
// Example: If app is deployed at /admin or /portal
|
||||||
|
if (path.startsWith("/admin")) return "/admin";
|
||||||
|
if (path.startsWith("/portal")) return "/portal"; // TODO: This is all temporary, we need to fix this properly
|
||||||
|
|
||||||
|
return "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData()
|
if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
|
||||||
|
{
|
||||||
|
qController.clearAuthenticationMetaDataLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData();
|
||||||
|
|
||||||
authenticationMetaDataPromise.then((authenticationMetaData) =>
|
authenticationMetaDataPromise.then((authenticationMetaData) =>
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
|
||||||
function Auth0ProviderWithRedirectCallback({children, ...props})
|
|
||||||
{
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// @ts-ignore
|
/***************************************************************************
|
||||||
const onRedirectCallback = (appState) =>
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
function Auth0RouterBody()
|
||||||
{
|
{
|
||||||
navigate((appState && appState.returnTo) || window.location.pathname);
|
const {renderAppWrapper} = useAuth0AuthenticationModule({});
|
||||||
};
|
return (renderAppWrapper(authenticationMetaData));
|
||||||
if (searchParams.get("error"))
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
function OAuth2RouterBody()
|
||||||
{
|
{
|
||||||
return (
|
const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
|
||||||
// @ts-ignore
|
return (renderAppWrapper(authenticationMetaData, (
|
||||||
<Auth0Provider {...props}>
|
<MaterialUIControllerProvider>
|
||||||
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
|
<App authenticationMetaData={authenticationMetaData} />
|
||||||
</Auth0Provider>
|
</MaterialUIControllerProvider>
|
||||||
);
|
)));
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
function AnonymousRouterBody()
|
||||||
{
|
{
|
||||||
return (
|
const {renderAppWrapper} = useAnonymousAuthenticationModule({});
|
||||||
// @ts-ignore
|
return (renderAppWrapper(authenticationMetaData, (
|
||||||
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
|
<MaterialUIControllerProvider>
|
||||||
{children}
|
<App authenticationMetaData={authenticationMetaData} />
|
||||||
</Auth0Provider>
|
</MaterialUIControllerProvider>
|
||||||
);
|
)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
if (authenticationMetaData.type === "AUTH_0")
|
if (authenticationMetaData.type === "AUTH_0")
|
||||||
{
|
{
|
||||||
// @ts-ignore
|
root.render(<BrowserRouter basename={getBasePath()}>
|
||||||
let domain: string = authenticationMetaData.data.baseUrl;
|
<Auth0RouterBody />
|
||||||
|
</BrowserRouter>);
|
||||||
// @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;
|
|
||||||
}
|
}
|
||||||
|
else if (authenticationMetaData.type === "OAUTH2")
|
||||||
if(domain.endsWith("/"))
|
|
||||||
{
|
{
|
||||||
/////////////////////////////////////////////////////////////////////////////////////
|
root.render(<BrowserRouter basename={getBasePath()}>
|
||||||
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
|
<OAuth2RouterBody />
|
||||||
/////////////////////////////////////////////////////////////////////////////////////
|
</BrowserRouter>);
|
||||||
domain = domain.replace(/\/$/, "");
|
|
||||||
}
|
}
|
||||||
|
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
||||||
root.render(
|
{
|
||||||
<BrowserRouter>
|
root.render(<BrowserRouter basename={getBasePath()}>
|
||||||
<Auth0ProviderWithRedirectCallback
|
<AnonymousRouterBody />
|
||||||
domain={domain}
|
</BrowserRouter>);
|
||||||
clientId={clientId}
|
|
||||||
audience={audience}
|
|
||||||
redirectUri={`${window.location.origin}/`}
|
|
||||||
>
|
|
||||||
<MaterialUIControllerProvider>
|
|
||||||
<ProtectedRoute component={App} />
|
|
||||||
</MaterialUIControllerProvider>
|
|
||||||
</Auth0ProviderWithRedirectCallback>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
root.render(
|
root.render(<div>
|
||||||
<BrowserRouter>
|
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
|
||||||
<MaterialUIControllerProvider>
|
</div>);
|
||||||
<App />
|
|
||||||
</MaterialUIControllerProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
});
|
||||||
|
@ -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);
|
||||||
|
qController.setGotAuthentication();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
252
src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx
Normal file
252
src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
qController.setGotAuthentication();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
188
src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx
Normal file
188
src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx
Normal 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);
|
||||||
|
qController.setGotAuthentication();
|
||||||
|
|
||||||
|
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);
|
||||||
|
qController.setGotAuthentication();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
@ -1,38 +0,0 @@
|
|||||||
/*
|
|
||||||
* QQQ - Low-code Application Framework for Engineers.
|
|
||||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
|
||||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
||||||
* contact@kingsrook.com
|
|
||||||
* https://github.com/Kingsrook/
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as
|
|
||||||
* published by the Free Software Foundation, either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useAuth0} from "@auth0/auth0-react";
|
|
||||||
import {Button} from "@mui/material";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
function AuthenticationButton()
|
|
||||||
{
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AuthenticationButton;
|
|
@ -23,8 +23,10 @@ import {Chip} from "@mui/material";
|
|||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {makeStyles} from "@mui/styles";
|
import {makeStyles} from "@mui/styles";
|
||||||
import Downshift from "downshift";
|
import Downshift from "downshift";
|
||||||
|
import {debounce} from "lodash";
|
||||||
import {arrayOf, func, string} from "prop-types";
|
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) => ({
|
const useStyles = makeStyles((theme: any) => ({
|
||||||
chip: {
|
chip: {
|
||||||
@ -34,21 +36,99 @@ const useStyles = makeStyles((theme: any) => ({
|
|||||||
|
|
||||||
function ChipTextField({...props})
|
function ChipTextField({...props})
|
||||||
{
|
{
|
||||||
|
const qController = Client.getInstance();
|
||||||
const classes = useStyles();
|
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 [inputValue, setInputValue] = useState("");
|
||||||
const [chips, setChips] = 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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
setChips(chipData);
|
setChips(chipData);
|
||||||
}, [chipData]);
|
chipsRef.current = chipData;
|
||||||
|
determineChipColors();
|
||||||
|
}, [JSON.stringify(chipData)]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
handleChipChange(chips);
|
handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
|
||||||
}, [chips, handleChipChange]);
|
}, [chipValidity, chipPVSIds, isMakingRequest]);
|
||||||
|
|
||||||
function handleKeyDown(event: any)
|
function handleKeyDown(event: any)
|
||||||
{
|
{
|
||||||
@ -64,11 +144,14 @@ function ChipTextField({...props})
|
|||||||
setInputValue("");
|
setInputValue("");
|
||||||
return;
|
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());
|
newChipList.push(event.target.value.trim());
|
||||||
setChips(newChipList);
|
setChips(newChipList);
|
||||||
setInputValue("");
|
|
||||||
}
|
}
|
||||||
else if (chips.length && !inputValue.length && event.key === "Backspace")
|
else if (chips.length && !inputValue.length && event.key === "Backspace")
|
||||||
{
|
{
|
||||||
@ -87,18 +170,26 @@ function ChipTextField({...props})
|
|||||||
setChips(newChipList);
|
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>; }; })
|
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
|
||||||
{
|
{
|
||||||
setInputValue(event.target.value);
|
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 (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@ -125,16 +216,16 @@ function ChipTextField({...props})
|
|||||||
startAdornment:
|
startAdornment:
|
||||||
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
|
<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
|
<Chip
|
||||||
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
|
onChange={determineChipColors}
|
||||||
key={`${item}-${i}`}
|
color={chipColors[index]}
|
||||||
|
key={`${item}-${index}`}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
label={item}
|
label={item}
|
||||||
className={classes.chip}
|
className={classes.chip}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>,
|
</div>,
|
||||||
@ -158,6 +249,7 @@ function ChipTextField({...props})
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ChipTextField.defaultProps = {
|
ChipTextField.defaultProps = {
|
||||||
chipData: []
|
chipData: []
|
||||||
};
|
};
|
||||||
@ -166,4 +258,4 @@ ChipTextField.propTypes = {
|
|||||||
chipData: arrayOf(string)
|
chipData: arrayOf(string)
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChipTextField
|
export default ChipTextField;
|
||||||
|
@ -20,17 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
|
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 Switch from "@mui/material/Switch";
|
||||||
import {ErrorMessage, Field, useFormikContext} from "formik";
|
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 colors from "qqq/assets/theme/base/colors";
|
||||||
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
|
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 MDInput from "qqq/components/legacy/MDInput";
|
||||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||||
|
import React, {useMemo, useState} from "react";
|
||||||
|
import AceEditor from "react-ace";
|
||||||
import {flushSync} from "react-dom";
|
import {flushSync} from "react-dom";
|
||||||
|
|
||||||
// Declaring props types for FormField
|
// Declaring props types for FormField
|
||||||
@ -83,7 +84,7 @@ function QDynamicFormField({
|
|||||||
|
|
||||||
if (placeholder)
|
if (placeholder)
|
||||||
{
|
{
|
||||||
inputProps.placeholder = placeholder
|
inputProps.placeholder = placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backgroundColor)
|
if (backgroundColor)
|
||||||
@ -167,7 +168,7 @@ function QDynamicFormField({
|
|||||||
{
|
{
|
||||||
if (onChangeCallback)
|
if (onChangeCallback)
|
||||||
{
|
{
|
||||||
onChangeCallback(newValue == null ? null : newValue.id)
|
onChangeCallback(newValue == null ? null : newValue.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +187,7 @@ function QDynamicFormField({
|
|||||||
onChange={dynamicSelectOnChange}
|
onChange={dynamicSelectOnChange}
|
||||||
// otherValues={otherValuesMap}
|
// otherValues={otherValuesMap}
|
||||||
useCase="form"
|
useCase="form"
|
||||||
/>)
|
/>);
|
||||||
}
|
}
|
||||||
else if (type === "checkbox")
|
else if (type === "checkbox")
|
||||||
{
|
{
|
||||||
|
@ -174,7 +174,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
|
|||||||
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
|
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
|
||||||
{
|
{
|
||||||
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
|
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
@ -184,13 +184,13 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
|
|||||||
{
|
{
|
||||||
if (possibleValues)
|
if (possibleValues)
|
||||||
{
|
{
|
||||||
return filterInlinePossibleValues(searchTerm, possibleValues)
|
return filterInlinePossibleValues(searchTerm, possibleValues);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
|
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, null, otherValues, useCase);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
|
@ -284,14 +284,14 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
|
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
const childTableMetaData = await qController.loadTableMetaData(childTableName)
|
const childTableMetaData = await qController.loadTableMetaData(childTableName);
|
||||||
for (let key in values)
|
for (let key in values)
|
||||||
{
|
{
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
const field = childTableMetaData.fields.get(key);
|
const field = childTableMetaData.fields.get(key);
|
||||||
if (field.possibleValueSourceName)
|
if (field.possibleValueSourceName)
|
||||||
{
|
{
|
||||||
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
|
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], null, objectToMap(values), "form");
|
||||||
if (possibleValues && possibleValues.length > 0)
|
if (possibleValues && possibleValues.length > 0)
|
||||||
{
|
{
|
||||||
displayValues[key] = possibleValues[0].label;
|
displayValues[key] = possibleValues[0].label;
|
||||||
@ -516,7 +516,6 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
**
|
**
|
||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
@ -532,7 +531,7 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
rs.set(key, object[key]);
|
rs.set(key, object[key]);
|
||||||
}
|
}
|
||||||
return rs
|
return rs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -667,7 +666,7 @@ function EntityForm(props: Props): JSX.Element
|
|||||||
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
|
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
|
||||||
if (defaultValue && fieldMetaData.possibleValueSourceName)
|
if (defaultValue && fieldMetaData.possibleValueSourceName)
|
||||||
{
|
{
|
||||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form");
|
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], null, objectToMap(initialValues), "form");
|
||||||
if (results && results.length > 0)
|
if (results && results.length > 0)
|
||||||
{
|
{
|
||||||
defaultDisplayValues.set(fieldName, results[0].label);
|
defaultDisplayValues.set(fieldName, results[0].label);
|
||||||
|
@ -20,14 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||||
|
import {Button} from "@mui/material";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Icon from "@mui/material/Icon";
|
import Icon from "@mui/material/Icon";
|
||||||
import Link from "@mui/material/Link";
|
import Link from "@mui/material/Link";
|
||||||
import List from "@mui/material/List";
|
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 SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse";
|
||||||
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
|
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
|
||||||
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
|
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
|
||||||
@ -36,6 +34,8 @@ import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
|
|||||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||||
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
|
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
|
||||||
|
import {ReactNode, useEffect, useReducer, useState} from "react";
|
||||||
|
import {NavLink, useLocation} from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
@ -45,6 +45,7 @@ interface Props
|
|||||||
logo?: string;
|
logo?: string;
|
||||||
appName?: string;
|
appName?: string;
|
||||||
branding?: QBrandingMetaData;
|
branding?: QBrandingMetaData;
|
||||||
|
logout: () => void;
|
||||||
routes: {
|
routes: {
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| ReactNode
|
| ReactNode
|
||||||
@ -67,7 +68,7 @@ interface Props
|
|||||||
[key: string]: any;
|
[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 [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
|
||||||
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
||||||
@ -370,7 +371,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
|||||||
(darkMode && !transparentSidenav && whiteSidenav)
|
(darkMode && !transparentSidenav && whiteSidenav)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AuthenticationButton />
|
<Button onClick={logout}>Log Out</Button>
|
||||||
</SidenavRoot>
|
</SidenavRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -207,7 +207,7 @@ function GotoRecordDialog(props: Props): JSX.Element
|
|||||||
const queryStringParts: string[] = [];
|
const queryStringParts: string[] = [];
|
||||||
options[optionIndex].forEach((field) =>
|
options[optionIndex].forEach((field) =>
|
||||||
{
|
{
|
||||||
if (field.type == QFieldType.STRING && !values[field.name][0])
|
if (field.type == QFieldType.STRING && !values[field.name])
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,6 @@ import Autocomplete from "@mui/material/Autocomplete";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Icon from "@mui/material/Icon";
|
import Icon from "@mui/material/Icon";
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import RadioGroup from "@mui/material/RadioGroup";
|
import RadioGroup from "@mui/material/RadioGroup";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {useFormikContext} from "formik";
|
import {useFormikContext} from "formik";
|
||||||
@ -104,7 +103,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
|
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, null, "filter");
|
||||||
if (possibleValues && possibleValues.length > 0)
|
if (possibleValues && possibleValues.length > 0)
|
||||||
{
|
{
|
||||||
setPossibleValueInitialDisplayValue(possibleValues[0].label);
|
setPossibleValueInitialDisplayValue(possibleValues[0].label);
|
||||||
@ -116,7 +115,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
|||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
console.log(`Error loading possible value: ${e}`)
|
console.log(`Error loading possible value: ${e}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
actuallyDoingInitialLoadOfPossibleValue = false;
|
actuallyDoingInitialLoadOfPossibleValue = false;
|
||||||
@ -150,7 +149,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
|||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
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]);
|
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {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 {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
@ -28,20 +32,26 @@ import Modal from "@mui/material/Modal";
|
|||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
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 {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||||
|
|
||||||
import ChipTextField from "qqq/components/forms/ChipTextField";
|
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
|
interface Props
|
||||||
{
|
{
|
||||||
type: string;
|
type: string;
|
||||||
onSave: (newValues: any[]) => void;
|
onSave: (newValues: any[]) => void;
|
||||||
|
table?: QTableMetaData;
|
||||||
|
field?: QFieldMetaData;
|
||||||
}
|
}
|
||||||
|
|
||||||
FilterCriteriaPaster.defaultProps = {};
|
FilterCriteriaPaster.defaultProps = {};
|
||||||
|
const qController = Client.getInstance();
|
||||||
|
|
||||||
function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
|
||||||
{
|
{
|
||||||
enum Delimiter
|
enum Delimiter
|
||||||
{
|
{
|
||||||
@ -68,6 +78,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
mainCardStyles.width = "60%";
|
mainCardStyles.width = "60%";
|
||||||
mainCardStyles.minWidth = "500px";
|
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);
|
//x const [gridFilterItem, setGridFilterItem] = useState(props.item);
|
||||||
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
|
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
@ -75,8 +91,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
const [delimiterCharacter, setDelimiterCharacter] = useState("");
|
const [delimiterCharacter, setDelimiterCharacter] = useState("");
|
||||||
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
|
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
|
||||||
const [chipData, setChipData] = useState(undefined);
|
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 [detectedText, setDetectedText] = useState("");
|
||||||
const [errorText, setErrorText] = 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 //
|
// handler for when paste icon is clicked in 'any' operator //
|
||||||
@ -92,6 +113,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
setDelimiter("");
|
setDelimiter("");
|
||||||
setDelimiterCharacter("");
|
setDelimiterCharacter("");
|
||||||
setChipData([]);
|
setChipData([]);
|
||||||
|
setChipValidity([]);
|
||||||
setInputText("");
|
setInputText("");
|
||||||
setDetectedText("");
|
setDetectedText("");
|
||||||
setCustomDelimiterValue("");
|
setCustomDelimiterValue("");
|
||||||
@ -106,17 +128,42 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
|
|
||||||
const handleSaveClicked = () =>
|
const handleSaveClicked = () =>
|
||||||
{
|
{
|
||||||
////////////////////////////////////////
|
///////////////////////////////////////////////////////////////
|
||||||
// if numeric remove any non-numerics //
|
// if numeric remove any non-numerics, or invalid pvs values //
|
||||||
////////////////////////////////////////
|
///////////////////////////////////////////////////////////////
|
||||||
let saveData = [];
|
let saveData = [];
|
||||||
|
let usedLabels = new Map<any, boolean>();
|
||||||
for (let i = 0; i < chipData.length; i++)
|
for (let i = 0; i < chipData.length; i++)
|
||||||
{
|
{
|
||||||
if (type !== "number" || !Number.isNaN(Number(chipData[i])))
|
if (chipValidity[i] === true)
|
||||||
|
{
|
||||||
|
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]);
|
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);
|
onSave(saveData);
|
||||||
|
|
||||||
@ -214,6 +261,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
(async () =>
|
||||||
|
{
|
||||||
|
const metaData = await qController.loadMetaData();
|
||||||
|
setMetaData(metaData);
|
||||||
|
})();
|
||||||
|
|
||||||
let currentDelimiter = delimiter;
|
let currentDelimiter = delimiter;
|
||||||
let currentDelimiterCharacter = delimiterCharacter;
|
let currentDelimiterCharacter = delimiterCharacter;
|
||||||
|
|
||||||
@ -246,10 +299,16 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
let parts = inputText.split(regex);
|
let parts = inputText.split(regex);
|
||||||
let chipData = [] as string[];
|
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 //
|
// if delimiter is empty string, dont split anything //
|
||||||
///////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////
|
||||||
setErrorText("");
|
setErrorText("");
|
||||||
|
let invalidCount = 0;
|
||||||
if (currentDelimiterCharacter !== "")
|
if (currentDelimiterCharacter !== "")
|
||||||
{
|
{
|
||||||
for (let i = 0; i < parts.length; i++)
|
for (let i = 0; i < parts.length; i++)
|
||||||
@ -259,20 +318,47 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
{
|
{
|
||||||
chipData.push(part);
|
chipData.push(part);
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////
|
||||||
// if numeric, check that first before pushing as a chip //
|
// if numeric or pvs, check validity and add to invalid count //
|
||||||
///////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////
|
||||||
if (type === "number" && Number.isNaN(Number(part)))
|
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 + "numbers 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);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@ -283,6 +369,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
pasteModalIsOpen &&
|
pasteModalIsOpen &&
|
||||||
(
|
(
|
||||||
<Modal open={pasteModalIsOpen}>
|
<Modal open={pasteModalIsOpen}>
|
||||||
|
<Box>
|
||||||
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
|
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
|
||||||
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
|
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
|
||||||
<Card sx={mainCardStyles}>
|
<Card sx={mainCardStyles}>
|
||||||
@ -290,11 +377,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid item pr={3} xs={12} lg={12}>
|
<Grid item pr={3} xs={12} lg={12}>
|
||||||
<Typography variant="h5">Bulk Add Filter Values</Typography>
|
<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">
|
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
|
||||||
Paste into the box on the left.
|
{formattedHelpContent}
|
||||||
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>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
@ -314,10 +403,25 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
|
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
|
||||||
<FormControl sx={{m: 1, width: "100%"}}>
|
<FormControl sx={{m: 1, width: "100%"}}>
|
||||||
<ChipTextField
|
<ChipTextField
|
||||||
handleChipChange={() =>
|
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}
|
chipData={chipData}
|
||||||
|
chipValidity={chipValidity}
|
||||||
chipType={type}
|
chipType={type}
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -377,7 +481,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
|
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={4} lg={4}>
|
||||||
{
|
{
|
||||||
errorText && chipData.length > 0 && (
|
errorText && chipData.length > 0 && (
|
||||||
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
|
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
|
||||||
@ -386,11 +490,19 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
</Box>
|
</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>
|
||||||
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
|
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={2} lg={2}>
|
||||||
{
|
{
|
||||||
chipData && chipData.length > 0 && (
|
chipData && chipData.length > 0 && (
|
||||||
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
|
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} {uniqueCount && `(${uniqueCount} unique)`}</Typography>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -401,12 +513,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
|
|||||||
onClickHandler={handleCancelClicked}
|
onClickHandler={handleCancelClicked}
|
||||||
iconName="cancel"
|
iconName="cancel"
|
||||||
disabled={false} />
|
disabled={false} />
|
||||||
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
|
<QSaveButton onClickHandler={handleSaveClicked} label="Add Values" disabled={saveDisabled} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
@ -398,11 +398,12 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
|||||||
initialValues = criteria.values;
|
initialValues = criteria.values;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <Box>
|
return <Box display="flex" alignItems="flex-end" className="multiValue">
|
||||||
|
<Box width={"100%"}>
|
||||||
<DynamicSelect
|
<DynamicSelect
|
||||||
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
|
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
|
||||||
overrideId={field.name + "-multi-" + criteria.id}
|
overrideId={field.name + "-multi-" + criteria.id}
|
||||||
key={field.name + "-multi-" + criteria.id}
|
key={field.name + "-multi-" + criteria.id + "-" + criteria.values.length}
|
||||||
isMultiple
|
isMultiple
|
||||||
fieldLabel="Values"
|
fieldLabel="Values"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
@ -412,6 +413,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
|||||||
variant="standard"
|
variant="standard"
|
||||||
useCase="filter"
|
useCase="filter"
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<FilterCriteriaPaster table={table} field={field} type="pvs" onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
|
||||||
|
</Box>
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
|
|||||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||||
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
|
import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
|
||||||
|
|
||||||
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
|
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
|
||||||
|
|
||||||
@ -186,6 +186,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
|||||||
//////////////////////
|
//////////////////////
|
||||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
// was not seeing criteria changes take place until watching it stringified //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////
|
||||||
|
setCriteria(criteria);
|
||||||
|
}, [JSON.stringify(criteria)]);
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
**
|
**
|
||||||
|
@ -102,7 +102,7 @@ export default class GoogleAnalyticsUtils
|
|||||||
console.log("Error reading session values from localStorage: " + e);
|
console.log("Error reading session values from localStorage: " + e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.metaData.environmentValues.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"))
|
if (this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID"))
|
||||||
{
|
{
|
||||||
this.active = true;
|
this.active = true;
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ class FilterUtils
|
|||||||
}
|
}
|
||||||
else
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,7 +267,13 @@ class ValueUtils
|
|||||||
{
|
{
|
||||||
if (!displayValue && field.defaultValue)
|
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)
|
if (field.type === QFieldType.DATE_TIME)
|
||||||
|
Reference in New Issue
Block a user