Initial version of record audits

This commit is contained in:
2023-01-20 10:37:15 -06:00
parent 7099ea87f7
commit 4a658e9a5c
4 changed files with 356 additions and 6 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<Box>
<Box p={3} display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start">
<Typography variant="h5" pb={3}>
Audit for {tableMetaData.label}: {record?.recordLabel ?? recordId}
<Typography fontSize={14}>
{statusString}
</Typography>
</Typography>
<Box>
<Typography variant="button" pr={1}>Sort</Typography>
<ToggleButtonGroup
value={sortDirection}
exclusive
onChange={changeSortDirection}
aria-label="text alignment"
>
<ToggleButton value={true} aria-label="sort ascending">
<Tooltip title="Sort by time ascending (oldest to newest)" placement="bottom">
<Icon>arrow_upward</Icon>
</Tooltip>
</ToggleButton>
<ToggleButton value={false} aria-label="sort descending">
<Tooltip title="Sort by time descending (newest to oldest)" placement="bottom">
<Icon>arrow_downward</Icon>
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
<Box sx={{overflow: "auto", height: "calc( 100vh - 19rem )", position: "relative"}} px={3}>
{
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 (
<Box key={audit0.values.get("id")} className="auditGroupBlock">
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
</Box>
{
audits.map((audit) =>
{
return (
<Box key={audit.values.get("id")} display="flex" flexDirection="row" mb={1} className="singleAuditBlock">
<Avatar sx={{bgcolor: colors.info.main, zIndex: 2}}>
<Icon>check</Icon>
</Avatar>
<Box p={1}>
<Box fontSize="0.875rem" color="rgb(123, 128, 154)">
{timestampParts[1]} {timestampParts[2]} {timestampParts[3]} &nbsp; {audit.displayValues.get("auditUserId")}
</Box>
<Box fontSize="1rem">
{audit.values.get("message")}
</Box>
</Box>
</Box>
);
})
}
</Box>
);
}
else
{
return <></>;
}
}) : <></>
}
</Box>
</Box>);
}
export default AuditBody;

View File

@ -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<string, JSX.Element[]>);
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
<ListItemIcon><Icon>data_object</Icon></ListItemIcon>
Developer Mode
</MenuItem>
{
metaData && metaData.tables.has("audit") &&
<MenuItem onClick={() =>
{
setActionsMenu(null);
navigate("#audit")
}}>
<ListItemIcon><Icon>checklist</Icon></ListItemIcon>
Audit
</MenuItem>
}
</Menu>
);
@ -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 (
<BaseLayout>
<Box>
@ -678,6 +726,24 @@ function RecordView({table, launchProcess}: Props): JSX.Element
</Modal>
}
{
showAudit && tableMetaData && record &&
<Modal open={showAudit} onClose={(event, reason) => closeAudit(event, reason)}>
<div className="audit">
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{my: 5, mx: "auto", pb: 0, maxWidth: "1024px"}}>
<Box component="div">
<AuditBody recordId={id} record={record} tableMetaData={tableMetaData} />
<Box p={3} display="flex" flexDirection="row" justifyContent="flex-end">
<QCancelButton label="Close" onClickHandler={() => closeAudit(null, null)} disabled={false} />
</Box>
</Box>
</Card>
</Box>
</div>
</Modal>
}
</Box>
}
</Box>

View File

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

View File

@ -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)