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") &&
+
+ }
);
@@ -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)