frontend/src/common/DiffHistory.tsx
2025-02-12 15:11:42 -06:00

593 lines
18 KiB
TypeScript

import React, { useState, useEffect } from "react";
// import { createStyles, Theme, makeStyles, fade } from "@material-ui/core/styles";
import { detailedDiff } from "deep-object-diff";
import {
List,
ListItem,
ListItemText,
ListItemIcon,
Chip,
Avatar,
Grid2 as Grid
} from "@mui/material";
import { Add, ChangeHistory, Remove, Face, Sync } from "@mui/icons-material";
import { amber, red, blue, green } from "@mui/material/colors";
// import MaterialTable from "material-table";
import moment from "moment";
// import { getTableIcons } from "common/ResponsiveTable";
import { pond } from "protobuf-ts/pond";
import { useUserAPI } from "hooks";
import { useMobile } from "hooks";
import ObjectDescriber from "objects/ObjectDescriber";
import { makeStyles } from "@mui/styles";
import { alpha, Theme } from "@mui/material";
import ResponsiveTable, { Column } from "./ResponsiveTable";
const addedColour = alpha(green[600], 0.25);
const deletedColour = alpha(red[600], 0.25);
const updatedColour = alpha(blue[600], 0.25);
const resyncedColour = alpha(amber[600], 0.25);
const useStyles = makeStyles((theme: Theme) => {
return ({
added: {
backgroundColor: addedColour
},
deleted: {
backgroundColor: deletedColour
},
updated: {
backgroundColor: updatedColour
},
resynced: {
backgroundColor: resyncedColour
},
chipContainer: {
maxWidth: "100%",
maxHeight: "100%",
flexWrap: "wrap"
},
translateContainer: {
borderLeft: "2px solid white",
borderRight: "2px solid white",
borderRadius: "6px",
padding: "0 8px 0 8px",
whiteSpace: "pre",
margin: "8px"
},
translateBox: {
display: "flex",
flexDirection: "row",
alignItems: "center",
padding: "0px"
}
})
});
interface Diff {
added: any;
deleted: any;
updated: any;
}
interface RowData {
timestamp: string;
user: string;
changes: number;
before: object;
after: object;
diff: Diff;
status: string;
type?: pond.ObjectType;
}
interface SummaryProps {
changes: number;
diff: Diff;
before: any;
after: any;
kind: string;
translateKey: (key: keyof any, type?: pond.ObjectType) => string;
translateValue: (key: keyof any, obj: any, type?: pond.ObjectType) => string;
variant?: "expanded" | "inline";
type?: pond.ObjectType;
}
function Summary(props: SummaryProps) {
const classes = useStyles();
const { changes, diff, before, after, translateKey, translateValue, variant, type } = props;
//console.log(diff)
const { added, updated, deleted } = diff;
const translate = (key: keyof any, changeType: "added" | "deleted" | "updated"): any => {
switch (changeType) {
case "updated":
return (
<div className={classes.translateBox}>
<div>{translateKey(key, type) + ":"}</div>
<div className={classes.translateContainer}>{translateValue(key, before, type)}</div>
<div style={{ fontSize: "20px" }}>{"→"}</div>
<div className={classes.translateContainer}>{translateValue(key, after, type)}</div>
</div>
);
case "deleted":
return translateKey(key, type);
default:
let val = translateValue(key, after, type);
if (isBool(val)) {
return translateKey(key, type);
}
return translateKey(key, type) + ": " + val;
}
};
const isBool = (val: string) => {
return val.toLowerCase() === "true" || val.toLowerCase() === "false";
};
const icon = (changeType: "added" | "deleted" | "updated") => {
switch (changeType) {
case "added":
return <Add />;
case "deleted":
return <Remove />;
default:
return <ChangeHistory />;
}
};
const inlineItem = (key: any, changeType: "added" | "deleted" | "updated") => {
if (key !== "key") {
return (
<Grid key={key} style={{ minWidth: "8rem" }}>
{key !== "key" && (
<Chip
className={
changeType === "added"
? classes.added
: changeType === "deleted"
? classes.deleted
: classes.updated
}
icon={icon(changeType)}
size="small"
style={{ height: "100%", margin: "auto", padding: "0.25rem" }}
label={<div style={{ whiteSpace: "pre" }}>{translate(key, changeType)}</div>}
/>
)}
</Grid>
);
}
return null;
};
const isMobile = useMobile();
const listItem = (key: any, changeType: "added" | "deleted" | "updated") => {
return (
<ListItem
key={key}
className={
changeType === "added"
? classes.added
: changeType === "deleted"
? classes.deleted
: classes.updated
}>
<div style={{ marginRight: "1rem" }}>{icon(changeType)}</div>
{isMobile ? (
<div style={{ fontSize: "12px" }}>{translate(key, changeType)}</div>
) : (
<ListItemText>{translate(key, changeType)}</ListItemText>
)}
</ListItem>
);
};
switch (variant) {
case "inline":
if (changes <= 0) {
return <Chip className={classes.resynced} icon={<Sync />} size="small" label="Resynced" />;
}
return (
<Grid container direction="row" spacing={1} className={classes.chipContainer}>
{added &&
Object.keys(added).length > 0 &&
Object.keys(added).map(key => inlineItem(key, "added"))}
{deleted &&
Object.keys(deleted).length > 0 &&
Object.keys(deleted).map(key => inlineItem(key, "deleted"))}
{updated &&
Object.keys(updated).length > 0 &&
Object.keys(updated).map(key => inlineItem(key, "updated"))}
</Grid>
);
default:
if (changes <= 0) {
return (
<List disablePadding>
<ListItem key={"resynced"} className={classes.resynced}>
<ListItemIcon>
<Sync />
</ListItemIcon>
<ListItemText>
Setting were resynced to ensure consistency with the cloud
</ListItemText>
</ListItem>
</List>
);
}
return (
<List disablePadding>
{added && Object.keys(added).length > 0 && (
<React.Fragment>{Object.keys(added).map(key => listItem(key, "added"))}</React.Fragment>
)}
{deleted && Object.keys(deleted).length > 0 && (
<React.Fragment>
{Object.keys(deleted).map(key => listItem(key, "deleted"))}
</React.Fragment>
)}
{updated && Object.keys(updated).length > 0 && (
<React.Fragment>
{Object.keys(updated).map(key => listItem(key, "updated"))}
</React.Fragment>
)}
</List>
);
}
}
export interface Record {
timestamp: string;
data: any;
user: string;
status: string;
type?: pond.ObjectType;
}
export interface ListResult {
records: Record[];
offset: number;
total: number;
}
interface Props {
name: string;
kind: string;
showTitle: boolean;
list: (limit: number, offset: number) => Promise<ListResult>;
translateKey: (key: keyof any, type?: pond.ObjectType) => string;
translateValue: (key: keyof any, obj: any, type?: pond.ObjectType) => string;
headerStyle?: React.CSSProperties;
cellStyle?: React.CSSProperties | ((data: RowData[], rowData: RowData) => React.CSSProperties);
noPaging?: boolean;
sortingEnabled?: boolean;
filteringEnabled?: boolean;
drawer?: boolean;
}
export default function DiffHistory(props: Props) {
const {
kind,
name,
headerStyle,
cellStyle,
list,
translateKey,
translateValue,
showTitle,
noPaging,
sortingEnabled,
filteringEnabled,
drawer
} = props;
const userAPI = useUserAPI();
const [pageSize, setPageSize] = useState(10);
const [profiles, setProfiles] = useState(new Map<string, pond.UserProfile>());
const isMobile = useMobile();
const [page, setPage] = useState(0);
const [data, setData] = useState<RowData[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
new Promise(resolve => {
list(pageSize, page * pageSize)
.then((res: ListResult) => {
let records = res.records;
let uniqueUsers = Array.from(
new Set(records.map((record: Record) => record.user).filter(id => !profiles.has(id)))
);
let req =
uniqueUsers.length > 0
? userAPI.getProfiles(uniqueUsers)
: new Promise<pond.UserProfile[]>(resolve => resolve([]));
req
.then((res: pond.UserProfile[]) => {
res.forEach((profile: pond.UserProfile) => profiles.set(profile.id, profile));
setProfiles(profiles);
})
.catch((err: any) => console.log(err))
.finally(() => {
let data: RowData[] = [];
for (let i = 0; i < records.length; i++) {
let record = records[i];
let timestamp = record.timestamp;
let user = record.user;
//let typeString = record.type ? ObjectDescriber(record.type).name : kind;
let before = {};
if (i < records.length - 1) {
if (record.type) {
let found = false;
let k = i;
while (!found && k < records.length - 1) {
let nextRecord = records[k + 1];
if (record.type === nextRecord.type) {
if (record.type === pond.ObjectType.OBJECT_TYPE_DEVICE) {
if (record.data.deviceId === nextRecord.data.deviceId) {
before = nextRecord.data;
found = true;
}
} else if (
record.type === pond.ObjectType.OBJECT_TYPE_COMPONENT ||
record.type === pond.ObjectType.OBJECT_TYPE_INTERACTION
) {
if (record.data.key === nextRecord.data.key) {
before = nextRecord.data;
found = true;
}
}
}
k++;
}
} else {
before = records[i + 1].data;
}
}
let after = record.data;
let diff = detailedDiff(before, after) as Diff;
let changes = countNumChanges(diff);
let type = record.type;
let status = record.status;
data.push({
timestamp,
user,
changes,
before,
after,
diff,
status,
type
});
}
setData(data);
setTotal(res.total);
resolve({ data: data, page: page, totalCount: res.total });
});
})
.catch((err: any) => {
setData([]);
resolve({ data: [], page: 0, totalCount: 0 });
});
});
}, [pageSize, page, kind, list, profiles, userAPI]);
const countNumChanges = (diff: Diff): number => {
const { added, updated, deleted } = diff;
let count = 0;
count = count + Object.keys(added).length;
count = count + Object.keys(deleted).length;
count = count + Object.keys(updated).length;
return count;
};
const getProfile = (id: string): pond.UserProfile => {
if (id === "system") {
return pond.UserProfile.create({ id: "system", name: "System" });
}
let profile = profiles.get(id);
return profile ? profile : pond.UserProfile.create();
};
const columns = (): Column<RowData>[] => {
return [{
title: "Time",
render: (row: RowData) => (
<div style={isMobile || drawer ? { minWidth: "4rem" } : {}}>
{moment(row.timestamp).calendar()}
</div>
),
// defaultSort: "asc",
// cellStyle: cellStyle,
// customSort: (a, b) => moment(b.timestamp).diff(moment(a.timestamp))
},
{
title: "Object Type",
render: row => {
if (row.type) {
return (<div>ObjectDescriber(row.type).name</div>)
}
return <div></div>
}
},
{
title: "User",
// cellStyle: cellStyle,
// field: "user",
render: row => {
const profile = getProfile(row.user);
const avatarURL = profile.avatar;
const name = profile.name ? profile.name : "User " + profile.id;
const avatar = avatarURL ? (
<Avatar alt={name} src={avatarURL} />
) : (
<Avatar alt={name}>
<Face />
</Avatar>
);
return (
<div style={isMobile || drawer ? { minWidth: "4rem" } : {}}>
<Chip variant="outlined" avatar={avatar} label={name} />
</div>
);
},
// customSort: (a, b) => a.user.localeCompare(b.user)
//customFilterAndSearch: (filt: string, row: RowData) => row.user.toLowerCase().includes(filt.toLowerCase())
},
{
title: "Status",
render: row => <div>row.status</div>
},
{
title: "Summary",
// cellStyle: cellStyle,
// hidden: isMobile || drawer,
render: row => (
<Summary
variant="inline"
kind={kind}
changes={row.changes}
before={row.before}
after={row.after}
diff={row.diff}
translateKey={translateKey}
translateValue={translateValue}
type={row.type}
/>
),
// sorting: false
}
]
}
const handleRowsPerPageChange = (event: any) => {
const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value
setPageSize(newRowsPerPage);
setPage(0); // Reset to the first page
}
return (
<ResponsiveTable<RowData>
title={name + " History"}
rows={data}
columns={columns()}
total={0}
pageSize={0}
page={0}
setPage={setPage}
handleRowsPerPageChange={handleRowsPerPageChange}
/>
)
// return (
// <MaterialTable
// title={name + " History"}
// onChangePage={e => {
// setPage(e);
// }}
// options={{
// paging: noPaging ? false : true,
// pageSize: pageSize,
// pageSizeOptions: [5, 10, 20],
// showTitle: showTitle,
// toolbar: showTitle,
// search: false,
// sorting: sortingEnabled ? true : false,
// columnsButton: showTitle,
// header: true,
// headerStyle: headerStyle,
// filtering: filteringEnabled
// }}
// localization={{
// body: { emptyDataSourceMessage: "No records found" }
// }}
// onChangeRowsPerPage={pageSize => setPageSize(pageSize)}
// columns={[
// {
// title: "Time",
// render: (row: RowData) => (
// <div style={isMobile || drawer ? { minWidth: "4rem" } : {}}>
// {moment(row.timestamp).calendar()}
// </div>
// ),
// defaultSort: "asc",
// cellStyle: cellStyle,
// customSort: (a, b) => moment(b.timestamp).diff(moment(a.timestamp))
// },
// {
// title: "Object Type",
// render: row => {
// if (row.type) {
// return ObjectDescriber(row.type).name;
// }
// }
// },
// {
// title: "User",
// cellStyle: cellStyle,
// field: "user",
// render: row => {
// const profile = getProfile(row.user);
// const avatarURL = profile.avatar;
// const name = profile.name ? profile.name : "User " + profile.id;
// const avatar = avatarURL ? (
// <Avatar alt={name} src={avatarURL} />
// ) : (
// <Avatar alt={name}>
// <Face />
// </Avatar>
// );
// return (
// <div style={isMobile || drawer ? { minWidth: "4rem" } : {}}>
// <Chip variant="outlined" avatar={avatar} label={name} />
// </div>
// );
// },
// customSort: (a, b) => a.user.localeCompare(b.user)
// //customFilterAndSearch: (filt: string, row: RowData) => row.user.toLowerCase().includes(filt.toLowerCase())
// },
// {
// title: "Status",
// render: row => row.status
// },
// {
// title: "Summary",
// cellStyle: cellStyle,
// hidden: isMobile || drawer,
// render: row => (
// <Summary
// variant="inline"
// kind={kind}
// changes={row.changes}
// before={row.before}
// after={row.after}
// diff={row.diff}
// translateKey={translateKey}
// translateValue={translateValue}
// type={row.type}
// />
// ),
// sorting: false
// }
// ]}
// icons={getTableIcons()}
// data={data}
// page={page}
// totalCount={total}
// detailPanel={row => {
// return (
// <Summary
// kind={kind}
// changes={row.changes}
// before={row.before}
// after={row.after}
// diff={row.diff}
// translateKey={translateKey}
// translateValue={translateValue}
// type={row.type}
// />
// );
// }}
// onRowClick={(event, row, togglePanel) => togglePanel && togglePanel()}
// />
// );
}