diff --git a/package.json b/package.json index bb9072d..cea87a5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.94", + "@kingsrook/qqq-frontend-core": "1.0.96", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", @@ -44,6 +44,7 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.0.0", + "react-ga4": "2.1.0", "react-github-btn": "1.2.1", "react-google-drive-picker": "^1.2.0", "react-markdown": "9.0.1", diff --git a/src/App.tsx b/src/App.tsx index 56484e6..4b68292 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,12 +33,8 @@ import CssBaseline from "@mui/material/CssBaseline"; import Icon from "@mui/material/Icon"; import {ThemeProvider} from "@mui/material/styles"; import {LicenseInfo} from "@mui/x-license-pro"; -import jwt_decode from "jwt-decode"; -import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react"; -import {useCookies} from "react-cookie"; -import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom"; -import {Md5} from "ts-md5/dist/md5"; import CommandMenu from "CommandMenu"; +import jwt_decode from "jwt-decode"; import QContext from "QContext"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import theme from "qqq/components/legacy/Theme"; @@ -53,8 +49,13 @@ import EntityEdit from "qqq/pages/records/edit/RecordEdit"; import RecordQuery from "qqq/pages/records/query/RecordQuery"; import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView"; import RecordView from "qqq/pages/records/view/RecordView"; +import GoogleAnalyticsUtils from "qqq/utils/GoogleAnalyticsUtils"; import Client from "qqq/utils/qqq/Client"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; +import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react"; +import {useCookies} from "react-cookie"; +import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom"; +import {Md5} from "ts-md5/dist/md5"; const qController = Client.getInstance(); @@ -160,7 +161,7 @@ export default function App() if (shouldStoreNewToken(accessToken, lsAccessToken)) { console.log("Sending accessToken to backend, requesting a sessionUUID..."); - const newSessionUuid = await qController.manageSession(accessToken, null); + const {uuid: newSessionUuid, values} = await qController.manageSession(accessToken, null); ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. // @@ -168,6 +169,7 @@ export default function App() // 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 @@ -654,7 +656,7 @@ export default function App() }, ); - const [pageHeader, setPageHeader] = useState("" as string | JSX.Element); + const [pageHeader, setPageHeaderState] = useState("" as string | JSX.Element); const [accentColor, setAccentColor] = useState("#0062FF"); const [accentColorLight, setAccentColorLight] = useState("#C0D6F7"); const [tableMetaData, setTableMetaData] = useState(null); @@ -663,6 +665,35 @@ export default function App() const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false); const [helpHelpActive] = useState(queryParams.has("helpHelp")); + const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils()); + + + /******************************************************************************* + ** + *******************************************************************************/ + function setPageHeader(header: string | JSX.Element) + { + setPageHeaderState(header); + if(typeof header == "string") + { + recordAnalytics(header) + } + else + { + recordAnalytics("Title not available") + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function recordAnalytics(title: string) + { + googleAnalyticsUtils.recordAnalytics(location, title) + } + + return ( appRoutes && ( @@ -682,6 +713,7 @@ export default function App() setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses), setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent), setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen), + recordAnalytics: recordAnalytics, pathToLabelMap: pathToLabelMap, branding: branding }}> diff --git a/src/QContext.tsx b/src/QContext.tsx index 797c970..3352816 100644 --- a/src/QContext.tsx +++ b/src/QContext.tsx @@ -47,6 +47,11 @@ interface QContext tableProcesses?: QProcessMetaData[]; setTableProcesses?: (tableProcesses: QProcessMetaData[]) => void; + /////////////////////////////////////////// + // function to record an analytics event // + /////////////////////////////////////////// + recordAnalytics?: (title: string) => void; + /////////////////////////////////// // constants - no setters needed // /////////////////////////////////// diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 1c5e016..5742ba0 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -623,25 +623,25 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => if (validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess) { - if (!e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) + if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) { e.preventDefault(); navigate(`${metaData?.getTablePathByName(tableName)}/create`); } - else if (!e.metaKey && e.key === "r") + else if (!e.metaKey && !e.ctrlKey && e.key === "r") { e.preventDefault(); updateTable("'r' keyboard event"); } /* // disable until we add a ... ref down to let us programmatically open Columns button - else if (! e.metaKey && e.key === "c") + else if (! e.metaKey && !e.ctrlKey && e.key === "c") { e.preventDefault() gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns) } */ - else if (!e.metaKey && e.key === "f") + else if (!e.metaKey && !e.ctrlKey && e.key === "f") { e.preventDefault(); diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 455ac6b..b678b8f 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -164,27 +164,27 @@ function RecordView({table, launchProcess}: Props): JSX.Element if (validType && !dotMenuOpen && !keyboardHelpOpen && !showAudit && !showEditChildForm) { - if (!e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) + if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) { e.preventDefault(); gotoCreate(); } - else if (!e.metaKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) + else if (!e.metaKey && !e.ctrlKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) { e.preventDefault(); navigate("edit"); } - else if (!e.metaKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) + else if (!e.metaKey && !e.ctrlKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) { e.preventDefault(); navigate("copy"); } - else if (!e.metaKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) + else if (!e.metaKey && !e.ctrlKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) { e.preventDefault(); handleClickDeleteButton(); } - else if (!e.metaKey && e.key === "a" && metaData && metaData.tables.has("audit")) + else if (!e.metaKey && !e.ctrlKey && e.key === "a" && metaData && metaData.tables.has("audit")) { e.preventDefault(); navigate("#audit"); diff --git a/src/qqq/utils/GoogleAnalyticsUtils.ts b/src/qqq/utils/GoogleAnalyticsUtils.ts new file mode 100644 index 0000000..3f5a894 --- /dev/null +++ b/src/qqq/utils/GoogleAnalyticsUtils.ts @@ -0,0 +1,115 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import Client from "qqq/utils/qqq/Client"; +import ReactGA from "react-ga4"; + + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Utilities for working with Google Analytics (through react-ga4)^ + *******************************************************************************/ +export default class GoogleAnalyticsUtils +{ + private metaData: QInstance = null; + private active: boolean = false; + + + /******************************************************************************* + ** + *******************************************************************************/ + constructor() + { + } + + + /******************************************************************************* + ** + *******************************************************************************/ + private send = (location: Location, title: string) => + { + if(!this.active) + { + return; + } + + ReactGA.send({hitType: "pageview", page: location.pathname + location.search, title: title}); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + private setup = async (): Promise => + { + this.metaData = await qController.loadMetaData(); + + let sessionValues: {[key: string]: any} = null; + try + { + sessionValues = JSON.parse(localStorage.getItem("sessionValues")); + } + catch(e) + { + console.log("Error reading session values from localStorage: " + e); + } + + if (this.metaData.environmentValues.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID")) + { + this.active = true; + + if(sessionValues && sessionValues["googleAnalyticsValues"]) + { + ReactGA.gtag("set", "user_properties", sessionValues["googleAnalyticsValues"]); + } + + ReactGA.initialize(this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"), + { + gaOptions: {}, + gtagOptions: {} + }); + } + else + { + this.active = false; + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public recordAnalytics = (location: Location, title: string) => + { + if(this.metaData == null) + { + (async () => + { + await this.setup(); + })() + } + + this.send(location, title); + } + +} \ No newline at end of file