From 34cb788d1f493c82ab54dc246c72aef27f43f464 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 8 Dec 2022 10:39:02 -0600 Subject: [PATCH] Add recently viewed / history; add AdornmentType.RENDER_HTML; add FieldValueListWidget --- src/index.tsx | 30 ++++- src/qqq/components/DashboardWidgets.tsx | 14 ++- src/qqq/components/EntityForm/index.tsx | 16 ++- src/qqq/components/Navbar/index.tsx | 65 ++++++++++- .../Widgets/FieldValueListWidget.tsx | 98 ++++++++++++++++ .../dashboards/Widgets/RecordGridWidget.tsx | 7 +- .../pages/dashboards/Widgets/StepperCard.tsx | 23 ++-- src/qqq/pages/entity-view/EntityView.tsx | 22 ++++ src/qqq/utils/HistoryUtils.tsx | 107 ++++++++++++++++++ src/qqq/utils/QValueUtils.tsx | 10 +- 10 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 src/qqq/pages/dashboards/Widgets/FieldValueListWidget.tsx create mode 100644 src/qqq/utils/HistoryUtils.tsx diff --git a/src/index.tsx b/src/index.tsx index 8b07d3b..6ed84b1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,12 @@ import ProtectedRoute from "qqq/auth0/protected-route"; import QClient from "qqq/utils/QClient"; const qController = QClient.getInstance(); + +if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1) +{ + qController.clearAuthenticationMetaDataLocalStorage() +} + const authenticationMetaDataPromise: Promise = qController.getAuthenticationMetaData() authenticationMetaDataPromise.then((authenticationMetaData) => @@ -69,8 +75,28 @@ authenticationMetaDataPromise.then((authenticationMetaData) => if (authenticationMetaData.type === "AUTH_0") { - const domain = process.env.REACT_APP_AUTH0_DOMAIN; - const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID; + // @ts-ignore + let domain: string = authenticationMetaData.data.baseUrl; + + // @ts-ignore + const clientId = authenticationMetaData.data.clientId; + + if(!domain || !clientId) + { + render( +
Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].
, + document.getElementById("root"), + ); + return; + } + + if(domain.endsWith("/")) + { + ///////////////////////////////////////////////////////////////////////////////////// + // auth0 lib fails if we have a trailing slash. be a bit more graceful than that. // + ///////////////////////////////////////////////////////////////////////////////////// + domain = domain.replace(/\/$/, ""); + } render( diff --git a/src/qqq/components/DashboardWidgets.tsx b/src/qqq/components/DashboardWidgets.tsx index 7c84f69..1b89e4b 100644 --- a/src/qqq/components/DashboardWidgets.tsx +++ b/src/qqq/components/DashboardWidgets.tsx @@ -32,6 +32,7 @@ import MDBox from "qqq/components/Temporary/MDBox"; import MDTypography from "qqq/components/Temporary/MDTypography"; import BarChart from "qqq/pages/dashboards/Widgets/BarChart"; import DefaultLineChart from "qqq/pages/dashboards/Widgets/DefaultLineChart"; +import FieldValueListWidget from "qqq/pages/dashboards/Widgets/FieldValueListWidget"; import HorizontalBarChart from "qqq/pages/dashboards/Widgets/HorizontalBarChart"; import MultiStatisticsCard from "qqq/pages/dashboards/Widgets/MultiStatisticsCard"; import ParentWidget from "qqq/pages/dashboards/Widgets/ParentWidget"; @@ -259,10 +260,19 @@ function DashboardWidgets({widgetMetaDataList, entityPrimaryKey, omitWrappingGri { widgetMetaData.type === "childRecordList" && ( widgetData && widgetData[i] && - + ) + } + { + widgetMetaData.type === "fieldValueList" && ( + widgetData && widgetData[i] && + ) } diff --git a/src/qqq/components/EntityForm/index.tsx b/src/qqq/components/EntityForm/index.tsx index 32d4b3f..9a1c90f 100644 --- a/src/qqq/components/EntityForm/index.tsx +++ b/src/qqq/components/EntityForm/index.tsx @@ -55,7 +55,7 @@ interface Props table?: QTableMetaData; closeModalHandler?: (event: object, reason: string) => void; defaultValues: { [key: string]: string }; - disabledFields: { [key: string]: boolean }; + disabledFields: { [key: string]: boolean } | string[]; } EntityForm.defaultProps = { @@ -265,9 +265,19 @@ function EntityForm(props: Props): JSX.Element if(disabledFields) { - for (let fieldName in disabledFields) + if(Array.isArray(disabledFields)) { - dynamicFormFields[fieldName].isEditable = false; + for (let i = 0; i < disabledFields.length; i++) + { + dynamicFormFields[disabledFields[i]].isEditable = false; + } + } + else + { + for (let fieldName in disabledFields) + { + dynamicFormFields[fieldName].isEditable = false; + } } } diff --git a/src/qqq/components/Navbar/index.tsx b/src/qqq/components/Navbar/index.tsx index 6142174..6297ffe 100644 --- a/src/qqq/components/Navbar/index.tsx +++ b/src/qqq/components/Navbar/index.tsx @@ -22,10 +22,12 @@ import AppBar from "@mui/material/AppBar"; import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; import Toolbar from "@mui/material/Toolbar"; -import {useState, useEffect} from "react"; -import {useLocation} from "react-router-dom"; +import React, {useState, useEffect} from "react"; +import {useLocation, useNavigate} from "react-router-dom"; import {useMaterialUIController, setTransparentNavbar, setMiniSidenav, setOpenConfigurator,} from "context"; import {navbar, navbarContainer, navbarRow, navbarIconButton, navbarDesktopMenu, navbarMobileMenu,} from "qqq/components/Navbar/styles"; import QBreadcrumbs, {routeToLabel} from "qqq/components/QBreadcrumbs"; @@ -33,6 +35,7 @@ import MDBadge from "qqq/components/Temporary/MDBadge"; import MDBox from "qqq/components/Temporary/MDBox"; import MDInput from "qqq/components/Temporary/MDInput"; import NotificationItem from "qqq/components/Temporary/NotificationItem"; +import HistoryUtils, {QHistoryEntry} from "qqq/utils/HistoryUtils"; // Declaring prop types for Navbar interface Props @@ -50,7 +53,9 @@ function Navbar({absolute, light, isMini}: Props): JSX.Element miniSidenav, transparentNavbar, fixedNavbar, openConfigurator, darkMode, } = controller; const [openMenu, setOpenMenu] = useState(false); + const [openHistory, setOpenHistory] = useState(false); const route = useLocation().pathname.split("/").slice(1); + const navigate = useNavigate(); useEffect(() => { @@ -88,6 +93,52 @@ function Navbar({absolute, light, isMini}: Props): JSX.Element const handleOpenMenu = (event: any) => setOpenMenu(event.currentTarget); const handleCloseMenu = () => setOpenMenu(false); + const handleHistory = (event: any) => + { + setOpenHistory(event.currentTarget); + } + + const handleCloseHistory = () => + { + setOpenHistory(false); + } + + const goToHistory = (entry: QHistoryEntry) => + { + navigate(entry.path); + handleCloseHistory(); + } + + const renderHistory = () => + { + const history = HistoryUtils.get(); + + return ( + +

Recently Viewed Records

+ {history.entries.reverse().map((entry) => + ( + goToHistory(entry)}> + {entry.iconName} + {entry.label} + + ) + )} +
+ ); + }; + + // Render the notifications menu const renderMenu = () => ( + + history + + {renderHistory()} . + */ + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {Skeleton} from "@mui/material"; +import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon"; +import Typography from "@mui/material/Typography"; +import React from "react"; +import QValueUtils from "qqq/utils/QValueUtils"; +import Widget from "./Widget"; + +interface Props +{ + title: string; + data: any; +} + +FieldValueListWidget.defaultProps = {}; + +function FieldValueListWidget({title, data}: Props): JSX.Element +{ + if(!data.fields || !data.record) + { + const skeletons = [75, 50, 90]; + return ( + + + {skeletons.map((s) => + ( + + + + + + + + + )) + } + + + ); + } + + const fields = data.fields.map((f: any) => new QFieldMetaData(f)); + const record = new QRecord(data.record); + const fieldLabelPrefixIconNames = data.fieldLabelPrefixIconNames ?? {}; + const fieldLabelPrefixIconColors = data.fieldLabelPrefixIconColors ?? {}; + const fieldIndentLevels = data.fieldIndentLevels ?? {}; + + return ( + + + { + fields.map((field: QFieldMetaData, index: number) => ( + + { + fieldLabelPrefixIconNames[field.name] && + {fieldLabelPrefixIconNames[field.name]} + } + { + field.label && + + {field.label}: + + } + + {QValueUtils.getDisplayValue(field, record, "view")} + + + )) + } + + + ); +} + +export default FieldValueListWidget; diff --git a/src/qqq/pages/dashboards/Widgets/RecordGridWidget.tsx b/src/qqq/pages/dashboards/Widgets/RecordGridWidget.tsx index 8f0a826..d05e45a 100644 --- a/src/qqq/pages/dashboards/Widgets/RecordGridWidget.tsx +++ b/src/qqq/pages/dashboards/Widgets/RecordGridWidget.tsx @@ -74,7 +74,12 @@ function RecordGridWidget({title, data, reloadWidgetCallback}: Props): JSX.Eleme const labelAdditionalComponentsRight: LabelComponent[] = [] if(data && data.canAddChildRecord) { - labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords)) + let disabledFields = data.disabledFieldsForNewChildRecords; + if(!disabledFields) + { + disabledFields = data.defaultValuesForNewChildRecords; + } + labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields)) } return ( diff --git a/src/qqq/pages/dashboards/Widgets/StepperCard.tsx b/src/qqq/pages/dashboards/Widgets/StepperCard.tsx index a2a121f..f9ef572 100644 --- a/src/qqq/pages/dashboards/Widgets/StepperCard.tsx +++ b/src/qqq/pages/dashboards/Widgets/StepperCard.tsx @@ -20,7 +20,7 @@ */ import {Check, Pending, RocketLaunch} from "@mui/icons-material"; -import {Skeleton, StepConnector} from "@mui/material"; +import {Icon, Skeleton, StepConnector} from "@mui/material"; import Step from "@mui/material/Step"; import StepLabel from "@mui/material/StepLabel"; import Stepper from "@mui/material/Stepper"; @@ -38,10 +38,11 @@ export interface StepperCardData title: string; activeStep: number; steps: { - icon: string; label: string; linkText: string; linkURL: string; + iconOverride: string; + colorOverride: string; }[]; } @@ -79,12 +80,12 @@ function StepperCard({data}: Props): JSX.Element { index < activeStep && ( - } sx={{ - color: "green", + {step.iconOverride} : } sx={{ + color: step.colorOverride ?? "green", fontSize: "35px", "& .MuiStepLabel-label.Mui-completed.MuiStepLabel-alternativeLabel": { - color: "green !important", + color: `${step.colorOverride ?? "green"} !important`, } }}>{step.label} @@ -93,12 +94,12 @@ function StepperCard({data}: Props): JSX.Element { index > activeStep && ( - } sx={{ - color: "#ced4da", + {step.iconOverride} : } sx={{ + color: step.colorOverride ?? "#ced4da", fontSize: "35px", "& .MuiStepLabel-label.MuiStepLabel-alternativeLabel": { - color: "#ced4da !important", + color: `${step.colorOverride ?? "#ced4da"} !important`, } }}>{step.label} @@ -107,12 +108,12 @@ function StepperCard({data}: Props): JSX.Element { index === activeStep && ( - } sx={{ - color: "#04aaef", + {step.iconOverride} : } sx={{ + color: step.colorOverride ?? "#04aaef", fontSize: "35px", "& .MuiStepLabel-label.MuiStepLabel-alternativeLabel": { - color: "#344767 !important", // Just text label (COMPLETED) + color: `${step.colorOverride ?? "#344767"} !important`, } }}>{step.label} { diff --git a/src/qqq/pages/entity-view/EntityView.tsx b/src/qqq/pages/entity-view/EntityView.tsx index c95dc6d..8c3ff88 100644 --- a/src/qqq/pages/entity-view/EntityView.tsx +++ b/src/qqq/pages/entity-view/EntityView.tsx @@ -54,6 +54,7 @@ import MDAlert from "qqq/components/Temporary/MDAlert"; import MDBox from "qqq/components/Temporary/MDBox"; import MDTypography from "qqq/components/Temporary/MDTypography"; import ProcessRun from "qqq/pages/process-run"; +import HistoryUtils from "qqq/utils/HistoryUtils"; import QClient from "qqq/utils/QClient"; import QProcessUtils from "qqq/utils/QProcessUtils"; import QTableUtils from "qqq/utils/QTableUtils"; @@ -109,6 +110,7 @@ function EntityView({table, launchProcess}: Props): JSX.Element const reload = () => { + setNotFoundMessage(null); setAsyncLoadInited(false); setTableMetaData(null); setRecord(null); @@ -258,6 +260,16 @@ function EntityView({table, launchProcess}: Props): JSX.Element if ((e as QException).status === "404") { setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`); + + try + { + HistoryUtils.ensurePathNotInHistory(location.pathname); + } + catch(e) + { + console.error("Error pushing history: " + e); + } + return; } } @@ -265,6 +277,16 @@ function EntityView({table, launchProcess}: Props): JSX.Element setPageHeader(record.recordLabel); + try + { + HistoryUtils.push({label: `${tableMetaData?.label}: ${record.recordLabel}`, path: location.pathname, iconName: table.iconName}) + } + catch(e) + { + console.error("Error pushing history: " + e); + } + + ///////////////////////////////////////////////// // define the sections, e.g., for the left-bar // ///////////////////////////////////////////////// diff --git a/src/qqq/utils/HistoryUtils.tsx b/src/qqq/utils/HistoryUtils.tsx new file mode 100644 index 0000000..90f51fa --- /dev/null +++ b/src/qqq/utils/HistoryUtils.tsx @@ -0,0 +1,107 @@ +/* + * 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 . + */ + +export interface QHistoryEntry +{ + iconName: string; + label: string; + path: string; + date?: Date; +} + +export interface QHistory +{ + entries: QHistoryEntry[]; +} + + +export default class HistoryUtils +{ + private static LS_KEY = "qqq.history"; + + + /******************************************************************************* + ** Push an entry into the history + *******************************************************************************/ + public static push = (entry: QHistoryEntry) => + { + const history = HistoryUtils.get(); + + if(!entry.date) + { + entry.date = new Date() + } + + for (let i = 0; i < history.entries.length; i++) + { + if(history.entries[i].path == entry.path) + { + history.entries.splice(i, 1); + } + } + + history.entries.push(entry); + + if(history.entries.length > 20) + { + history.entries.splice(0, history.entries.length - 3); + } + + localStorage.setItem(HistoryUtils.LS_KEY, JSON.stringify(history)); + }; + + + + /******************************************************************************* + ** Get the history + *******************************************************************************/ + public static get = (): QHistory => + { + const existingJSON = localStorage.getItem(HistoryUtils.LS_KEY); + const history: QHistory = existingJSON ? JSON.parse(existingJSON) : {} + if(!history.entries) + { + history.entries = []; + } + + return (history); + }; + + + /******************************************************************************* + ** make sure a specific path isn't in the history (e.g., after a 404) + *******************************************************************************/ + public static ensurePathNotInHistory(path: string) + { + const history = HistoryUtils.get(); + + for (let i = 0; i < history.entries.length; i++) + { + if(history.entries[i].path == path) + { + history.entries.splice(i, 1); + } + } + + localStorage.setItem(HistoryUtils.LS_KEY, JSON.stringify(history)); + } +} + diff --git a/src/qqq/utils/QValueUtils.tsx b/src/qqq/utils/QValueUtils.tsx index 34048a4..50f94f4 100644 --- a/src/qqq/utils/QValueUtils.tsx +++ b/src/qqq/utils/QValueUtils.tsx @@ -26,6 +26,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import "datejs"; import {Box, Chip, Icon} from "@mui/material"; +import parse from "html-react-parser"; import React, {Fragment} from "react"; import AceEditor from "react-ace"; import {Link} from "react-router-dom"; @@ -67,7 +68,7 @@ class QValueUtils ** When you have a field, and a record - call this method to get a string or ** element back to display the field's value. *******************************************************************************/ - public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view"): string | JSX.Element + public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[] { const displayValue = record.displayValues ? record.displayValues.get(field.name) : undefined; const rawValue = record.values ? record.values.get(field.name) : undefined; @@ -79,7 +80,7 @@ class QValueUtils ** When you have a field and a value (either just a raw value, or a raw and ** display value), call this method to get a string Element to display. *******************************************************************************/ - public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element + public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[] { if (field.hasAdornment(AdornmentType.LINK)) { @@ -128,6 +129,11 @@ class QValueUtils } } + if (field.hasAdornment(AdornmentType.RENDER_HTML)) + { + return (parse(rawValue)); + } + if (field.hasAdornment(AdornmentType.CHIP)) { if (!displayValue)