From 4a658e9a5cbd4f6d6889bd0841576c451b4e110c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 20 Jan 2023 10:37:15 -0600 Subject: [PATCH] Initial version of record audits --- src/qqq/components/audits/AuditBody.tsx | 256 ++++++++++++++++++++++ src/qqq/pages/records/view/RecordView.tsx | 76 ++++++- src/qqq/styles/qqq-override-styles.css | 18 ++ src/qqq/utils/qqq/ValueUtils.tsx | 12 +- 4 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 src/qqq/components/audits/AuditBody.tsx diff --git a/src/qqq/components/audits/AuditBody.tsx b/src/qqq/components/audits/AuditBody.tsx new file mode 100644 index 0000000..9dc96c5 --- /dev/null +++ b/src/qqq/components/audits/AuditBody.tsx @@ -0,0 +1,256 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon/Icon"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import {useEffect, useState} from "react"; +import colors from "qqq/components/legacy/colors"; +import Client from "qqq/utils/qqq/Client"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface Props +{ + tableMetaData: QTableMetaData; + recordId: any; + record: QRecord; +} + +AuditBody.defaultProps = + {}; + +const qController = Client.getInstance(); + +function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element +{ + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const [audits, setAudits] = useState([] as QRecord[]); + const [total, setTotal] = useState(null as number); + const [limit, setLimit] = useState(1000); + const [statusString, setStatusString] = useState("Loading audits..."); + const [auditsByDate, setAuditsByDate] = useState([] as QRecord[][]); + const [sortDirection, setSortDirection] = useState(false); + + useEffect(() => + { + (async () => + { + ///////////////////////////////// + // setup filter to load audits // + ///////////////////////////////// + const filter = new QQueryFilter([ + new QFilterCriteria("auditTable.name", QCriteriaOperator.EQUALS, [tableMetaData.name]), + new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, [recordId]), + ], [ + new QFilterOrderBy("timestamp", sortDirection), + new QFilterOrderBy("id", sortDirection) + ]); + + /////////////////////////////// + // fetch audits in try-catch // + /////////////////////////////// + let audits = [] as QRecord[] + try + { + audits = await qController.query("audit", filter, limit, 0); + setAudits(audits); + } + catch(e) + { + if (e instanceof QException) + { + if ((e as QException).status === "403") + { + setStatusString("You do not have permission to view audits"); + return; + } + } + + setStatusString("Error loading audits"); + } + + // if we fetched the limit + if (audits.length == limit) + { + const count = await qController.count("audit", filter); + setTotal(count); + } + + setInitialLoadComplete(true); + + const auditsByDate = []; + let thisDatesAudits = null as QRecord[]; + let lastDate = null; + for (let i = 0; i < audits.length; i++) + { + const audit = audits[i]; + const date = ValueUtils.formatDateTime(audit.values.get("timestamp")).split(" ")[0]; + if (date != lastDate) + { + thisDatesAudits = []; + auditsByDate.push(thisDatesAudits); + lastDate = date; + } + thisDatesAudits.push(audit); + } + setAuditsByDate(auditsByDate); + + /////////////////////////// + // set the status string // + /////////////////////////// + if (audits.length == 0) + { + setStatusString("No audits were found for this record."); + } + else + { + if (total) + { + setStatusString(`Showing first ${limit?.toLocaleString()} of ${total?.toLocaleString()} audits for this record`); + } + else + { + if (audits.length == 1) + { + setStatusString("Showing the only audit for this record"); + } + else if (audits.length == 2) + { + setStatusString("Showing the only 2 audits for this record"); + } + else + { + setStatusString(`Showing all ${audits.length?.toLocaleString()} audits for this record`); + } + } + } + } + )(); + }, [sortDirection]); + + const changeSortDirection = () => + { + setAudits([]); + setSortDirection(!sortDirection); + }; + + const todayFormatted = ValueUtils.formatDateTime(new Date()).split(" ")[0]; + const yesterday = new Date(); + yesterday.setTime(yesterday.getTime() - 24 * 60 * 60 * 1000); + const yesterdayFormatted = ValueUtils.formatDateTime(yesterday).split(" ")[0]; + + return ( + + + + Audit for {tableMetaData.label}: {record?.recordLabel ?? recordId} + + {statusString} + + + + Sort + + + + arrow_upward + + + + + arrow_downward + + + + + + + { + auditsByDate.length ? auditsByDate.map((audits) => + { + if (audits.length) + { + const audit0 = audits[0]; + const formattedTimestamp = ValueUtils.formatDateTime(audit0.values.get("timestamp")); + const timestampParts = formattedTimestamp.split(" "); + + return ( + + + + + {ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]} + {timestampParts[0] == todayFormatted ? " (Today)" : ""} + {timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""} + + + + + { + audits.map((audit) => + { + return ( + + + check + + + + {timestampParts[1]} {timestampParts[2]} {timestampParts[3]}   {audit.displayValues.get("auditUserId")} + + + {audit.values.get("message")} + + + + ); + }) + } + + ); + } + else + { + return <>; + } + }) : <> + } + + ); +} + +export default AuditBody; diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index b9a49c3..4ccdadf 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -21,6 +21,7 @@ import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException"; import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; @@ -45,7 +46,8 @@ import Modal from "@mui/material/Modal"; import React, {useContext, useEffect, useReducer, useState} from "react"; import {useLocation, useNavigate, useParams, useSearchParams} from "react-router-dom"; import QContext from "QContext"; -import {QActionsMenuButton, QDeleteButton, QEditButton} from "qqq/components/buttons/DefaultButtons"; +import AuditBody from "qqq/components/audits/AuditBody"; +import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import EntityForm from "qqq/components/forms/EntityForm"; import colors from "qqq/components/legacy/colors"; import QRecordSidebar from "qqq/components/misc/RecordSidebar"; @@ -86,6 +88,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element const [sectionFieldElements, setSectionFieldElements] = useState(null as Map); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [tableMetaData, setTableMetaData] = useState(null); + const [metaData, setMetaData] = useState(null as QInstance); const [record, setRecord] = useState(null as QRecord); const [tableSections, setTableSections] = useState([] as QTableSection[]); const [t1SectionName, setT1SectionName] = useState(null as string); @@ -103,6 +106,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element const [launchingProcess, setLaunchingProcess] = useState(launchProcess); const [showEditChildForm, setShowEditChildForm] = useState(null as any); + const [showAudit, setShowAudit] = useState(false); const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget); const closeActionsMenu = () => setActionsMenu(null); @@ -174,6 +178,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element //////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if our table is in the -4 index, and there's `createChild` in the -2 index, try to open a createChild form // + // e.g., person/42/createChild/address (to create an address under person 42) // //////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == "createChild") { @@ -186,10 +191,11 @@ function RecordView({table, launchProcess}: Props): JSX.Element return; } - ///////////////////////////////////////////////////////////////////// - // alternatively, look for a createChild specification in the hash // - // e.g., for non-natively rendered links to open the modal. // - ///////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + // alternatively, look for a createChild specification in the hash // + // e.g., for non-natively rendered links to open the modal. // + // e.g., person/42#createChild=address (to create an address under person 42) // + //////////////////////////////////////////////////////////////////////////////// for (let i = 0; i < hashParts.length; i++) { const parts = hashParts[i].split("=") @@ -205,6 +211,12 @@ function RecordView({table, launchProcess}: Props): JSX.Element } } + if(hashParts[0] == "#audit") + { + setShowAudit(true); + return; + } + /////////////////////////////////////////////////////////////////////////////////// // look for anchor links - e.g., table section names. return w/ no-op if found. // /////////////////////////////////////////////////////////////////////////////////// @@ -247,6 +259,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element // load top-level meta-data (e.g., to find processes for table) // ////////////////////////////////////////////////////////////////// const metaData = await qController.loadMetaData(); + setMetaData(metaData); ValueUtils.qInstance = metaData; const processesForTable = ProcessUtils.getProcessesForTable(metaData, tableName); setTableProcesses(processesForTable); @@ -490,6 +503,17 @@ function RecordView({table, launchProcess}: Props): JSX.Element data_object Developer Mode + { + metaData && metaData.tables.has("audit") && + + { + setActionsMenu(null); + navigate("#audit") + }}> + checklist + Audit + + } ); @@ -558,6 +582,30 @@ function RecordView({table, launchProcess}: Props): JSX.Element setShowEditChildForm(null); }; + const closeAudit = (event: object, reason: string) => + { + if (reason === "backdropClick" || reason === "escapeKeyDown") + { + return; + } + + setShowAudit(false); + + ///////////////////////////////////////////////// + // navigate back up to the record being viewed // + ///////////////////////////////////////////////// + if(location.hash) + { + navigate(location.pathname); + } + else + { + const newPath = location.pathname.split("/"); + newPath.pop(); + navigate(newPath.join("/")); + } + }; + return ( @@ -678,6 +726,24 @@ function RecordView({table, launchProcess}: Props): JSX.Element } + { + showAudit && tableMetaData && record && + closeAudit(event, reason)}> +
+ + + + + + closeAudit(null, null)} disabled={false} /> + + + + +
+
+ } +
}
diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 42a8029..590a38b 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -293,3 +293,21 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } color: blue; font-size: 14px; } + + +.auditGroupBlock +{ + position: relative; +} + +.auditGroupBlock .singleAuditBlock::before +{ + content: ""; + position: absolute; + top: 2rem; + left: 19px; + height: calc(100% - 60px); + z-index: 1; + opacity: 1; + border-right: 0.0625rem solid rgb(222, 226, 230); +} \ No newline at end of file diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 4eb3bd1..080ff62 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -24,7 +24,7 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import "datejs"; +import "datejs"; // https://github.com/datejs/Datejs import {Box, Chip, Icon} from "@mui/material"; import parse from "html-react-parser"; import React, {Fragment} from "react"; @@ -253,6 +253,16 @@ class ValueUtils return (`${date.toString("yyyy-MM-dd hh:mm:ss")} ${date.getHours() < 12 ? "AM" : "PM"} ${date.getTimezone()}`); } + public static getFullWeekday(date: Date) + { + if(!(date instanceof Date)) + { + date = new Date(date) + } + // @ts-ignore + return (`${date.toString("dddd")}`); + } + public static formatBoolean(value: any) { if(value === true)