593 lines
18 KiB
TypeScript
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()}
|
|
// />
|
|
// );
|
|
}
|