mirror of
https://github.com/rowyio/rowy.git
synced 2026-02-24 04:01:17 +01:00
add audit fields
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@hookform/resolvers": "^2.8.1",
|
||||
"@mdi/js": "^5.9.55",
|
||||
"@mdi/js": "^6.2.95",
|
||||
"@monaco-editor/react": "^4.1.0",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/lab": "^5.0.0-alpha.50",
|
||||
|
||||
10
src/assets/icons/CreatedAt.tsx
Normal file
10
src/assets/icons/CreatedAt.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
|
||||
import { mdiClockPlusOutline } from "@mdi/js";
|
||||
|
||||
export default function CreatedAt(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d={mdiClockPlusOutline} />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
9
src/assets/icons/UpdatedAt.tsx
Normal file
9
src/assets/icons/UpdatedAt.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
|
||||
|
||||
export default function UpdatedAt(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="m19.06 14.88 2.05 2-6 6.07H13v-2.01l6.06-6.06ZM12 2a10 10 0 0 1 9.98 9.373 2.561 2.561 0 0 0-2.001.047A8 8 0 0 0 4 12a8.001 8.001 0 0 0 7 7.938v2.013C5.941 21.447 2 17.164 2 12 2 6.477 6.477 2 12 2Zm9.42 11.35 1.28 1.28c.21.21.21.56 0 .77l-1 .95-2.05-2 1-1a.55.55 0 0 1 .77 0ZM12.5 7v5.25l4.018 2.384-1.051 1.045L11 13V7h1.5Z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { useState, useEffect } from "react";
|
||||
import _camel from "lodash/camelCase";
|
||||
import { IMenuModalProps } from ".";
|
||||
|
||||
import { TextField } from "@mui/material";
|
||||
import { TextField, Typography, Button } from "@mui/material";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import { FieldType } from "constants/fields";
|
||||
import FieldsDropdown from "./FieldsDropdown";
|
||||
|
||||
import { FieldType } from "constants/fields";
|
||||
import { getFieldProp } from "components/fields";
|
||||
import { analytics } from "analytics";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export interface INewColumnProps extends IMenuModalProps {
|
||||
data: Record<string, any>;
|
||||
@@ -22,21 +24,52 @@ export default function NewColumn({
|
||||
handleClose,
|
||||
handleSave,
|
||||
}: INewColumnProps) {
|
||||
const { table, settingsActions } = useProjectContext();
|
||||
|
||||
const [columnLabel, setColumnLabel] = useState("");
|
||||
const [fieldKey, setFieldKey] = useState("");
|
||||
const [type, setType] = useState(FieldType.shortText);
|
||||
const requireConfiguration = getFieldProp("requireConfiguration", type);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== FieldType.id) setFieldKey(_camel(columnLabel));
|
||||
}, [columnLabel]);
|
||||
const isAuditField =
|
||||
type === FieldType.createdBy ||
|
||||
type === FieldType.createdAt ||
|
||||
type === FieldType.updatedBy ||
|
||||
type === FieldType.updatedAt;
|
||||
|
||||
useEffect(() => {
|
||||
if (type === FieldType.id) {
|
||||
setColumnLabel("ID");
|
||||
setFieldKey("id");
|
||||
if (type !== FieldType.id && !isAuditField)
|
||||
setFieldKey(_camel(columnLabel));
|
||||
}, [columnLabel, type, isAuditField]);
|
||||
|
||||
useEffect(() => {
|
||||
switch (type) {
|
||||
case FieldType.id:
|
||||
setColumnLabel("ID");
|
||||
setFieldKey("id");
|
||||
break;
|
||||
case FieldType.createdBy:
|
||||
setColumnLabel("Created By");
|
||||
setFieldKey(table?.auditFieldCreatedBy || "_createdBy");
|
||||
break;
|
||||
case FieldType.updatedBy:
|
||||
setColumnLabel("Updated By");
|
||||
setFieldKey(table?.auditFieldUpdatedBy || "_updatedBy");
|
||||
break;
|
||||
case FieldType.createdAt:
|
||||
setColumnLabel("Created At");
|
||||
setFieldKey(
|
||||
(table?.auditFieldCreatedBy || "_createdBy") + ".timestamp"
|
||||
);
|
||||
break;
|
||||
case FieldType.updatedAt:
|
||||
setColumnLabel("Updated At");
|
||||
setFieldKey(
|
||||
(table?.auditFieldUpdatedBy || "_updatedBy") + ".timestamp"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}, [type]);
|
||||
}, [type, table?.auditFieldCreatedBy, table?.auditFieldUpdatedBy]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -71,14 +104,35 @@ export default function NewColumn({
|
||||
type="text"
|
||||
fullWidth
|
||||
onChange={(e) => setFieldKey(e.target.value)}
|
||||
disabled={type === FieldType.id && fieldKey === "id"}
|
||||
disabled={
|
||||
(type === FieldType.id && fieldKey === "id") || isAuditField
|
||||
}
|
||||
helperText="Set the Firestore field key to link to this column. It will display any existing data for this field key."
|
||||
sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<FieldsDropdown value={type} onChange={setType} />
|
||||
</section>
|
||||
|
||||
{isAuditField && table?.audit === false && (
|
||||
<section>
|
||||
<Typography gutterBottom>
|
||||
This field requires auditing to be enabled on this table.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
settingsActions?.updateTable({ id: table.id, audit: true });
|
||||
}}
|
||||
>
|
||||
Enable auditing on this table
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={{
|
||||
|
||||
@@ -255,6 +255,39 @@ export const tableSettings = (
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
type: FieldType.contentHeader,
|
||||
name: "_contentHeader_audit",
|
||||
label: "Auditing",
|
||||
},
|
||||
{
|
||||
type: FieldType.checkbox,
|
||||
name: "audit",
|
||||
label: "Enable auditing for this table",
|
||||
defaultValue: true,
|
||||
assistiveText: "Track when users create or update rows",
|
||||
},
|
||||
{
|
||||
type: FieldType.shortText,
|
||||
name: "auditFieldCreatedBy",
|
||||
label: "Created By field key (optional)",
|
||||
defaultValue: "_createdBy",
|
||||
displayCondition: "return values.audit",
|
||||
assistiveText: "Optionally change the field key",
|
||||
gridCols: { xs: 12, sm: 6 },
|
||||
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
|
||||
},
|
||||
{
|
||||
type: FieldType.shortText,
|
||||
name: "auditFieldUpdatedBy",
|
||||
label: "Updated By field key (optional)",
|
||||
defaultValue: "_updatedBy",
|
||||
displayCondition: "return values.audit",
|
||||
assistiveText: "Optionally change the field key",
|
||||
gridCols: { xs: 12, sm: 6 },
|
||||
sx: { "& .MuiInputBase-input": { fontFamily: "mono" } },
|
||||
},
|
||||
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.contentHeader,
|
||||
@@ -289,20 +322,46 @@ export const tableSettings = (
|
||||
type: FieldType.contentSubHeader,
|
||||
name: "_contentSubHeader_initialColumns",
|
||||
label: "Initial columns",
|
||||
}
|
||||
: null,
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.checkbox,
|
||||
name: `_initialColumns.${TableFieldType.updatedBy}`,
|
||||
label: "Updated By: Automatically log who updates a row",
|
||||
sx: { "&&": { mb: 1 }, typography: "button", ml: 2 / 8 },
|
||||
}
|
||||
: null,
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.checkbox,
|
||||
name: `_initialColumns.${TableFieldType.createdBy}`,
|
||||
label: "Created By: Automatically log who creates a row",
|
||||
label: "Created By",
|
||||
displayCondition: "return values.audit",
|
||||
gridCols: 6,
|
||||
disablePaddingTop: true,
|
||||
}
|
||||
: null,
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.checkbox,
|
||||
name: `_initialColumns.${TableFieldType.updatedBy}`,
|
||||
label: "Updated By",
|
||||
displayCondition: "return values.audit",
|
||||
gridCols: 6,
|
||||
disablePaddingTop: true,
|
||||
}
|
||||
: null,
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.checkbox,
|
||||
name: `_initialColumns.${TableFieldType.createdAt}`,
|
||||
label: "Created At",
|
||||
displayCondition: "return values.audit",
|
||||
gridCols: 6,
|
||||
disablePaddingTop: true,
|
||||
}
|
||||
: null,
|
||||
mode === TableSettingsDialogModes.create
|
||||
? {
|
||||
type: FieldType.checkbox,
|
||||
name: `_initialColumns.${TableFieldType.updatedAt}`,
|
||||
label: "Updated At",
|
||||
displayCondition: "return values.audit",
|
||||
gridCols: 6,
|
||||
disablePaddingTop: true,
|
||||
}
|
||||
: null,
|
||||
|
||||
34
src/components/fields/CreatedAt/SideDrawerField.tsx
Normal file
34
src/components/fields/CreatedAt/SideDrawerField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ISideDrawerFieldProps } from "../types";
|
||||
|
||||
import { useFieldStyles } from "components/SideDrawer/Form/utils";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function CreatedAt({ control, column }: ISideDrawerFieldProps) {
|
||||
const fieldClasses = useFieldStyles();
|
||||
|
||||
const { table } = useProjectContext();
|
||||
const value = useWatch({
|
||||
control,
|
||||
name: table?.auditFieldCreatedBy || "_createdBy",
|
||||
});
|
||||
|
||||
if (!value || !value.timestamp) return <div className={fieldClasses.root} />;
|
||||
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={fieldClasses.root}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{dateLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/fields/CreatedAt/TableCell.tsx
Normal file
20
src/components/fields/CreatedAt/TableCell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IHeavyCellProps } from "../types";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function CreatedAt({ row, column }: IHeavyCellProps) {
|
||||
const { table } = useProjectContext();
|
||||
const value = row[table?.auditFieldCreatedBy || "_createdBy"];
|
||||
|
||||
if (!value || !value.timestamp) return null;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{dateLabel}</span>
|
||||
);
|
||||
}
|
||||
36
src/components/fields/CreatedAt/index.tsx
Normal file
36
src/components/fields/CreatedAt/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { lazy } from "react";
|
||||
import { IFieldConfig, FieldType } from "components/fields/types";
|
||||
import withHeavyCell from "../_withTableCell/withHeavyCell";
|
||||
|
||||
import CreatedAtIcon from "assets/icons/CreatedAt";
|
||||
import BasicCell from "../_BasicCell/BasicCellNull";
|
||||
import withSideDrawerEditor from "components/Table/editors/withSideDrawerEditor";
|
||||
|
||||
const TableCell = lazy(
|
||||
() => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-CreatedAt" */
|
||||
)
|
||||
);
|
||||
const Settings = lazy(
|
||||
() =>
|
||||
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.createdAt,
|
||||
name: "Created At",
|
||||
group: "Auditing",
|
||||
dataType: "firebase.firestore.Timestamp",
|
||||
initialValue: null,
|
||||
icon: <CreatedAtIcon />,
|
||||
description: "Displays the timestamp of when the row was created. Read-only.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
46
src/components/fields/CreatedBy/Settings.tsx
Normal file
46
src/components/fields/CreatedBy/Settings.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ISettingsProps } from "../types";
|
||||
|
||||
import { Typography, Link } from "@mui/material";
|
||||
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
|
||||
|
||||
import MultiSelect from "@rowy/multiselect";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { EXTERNAL_LINKS } from "constants/externalLinks";
|
||||
|
||||
export default function Settings({ handleChange, config }: ISettingsProps) {
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
options={[
|
||||
DATE_TIME_FORMAT,
|
||||
"yyyy/MM/dd HH:mm",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy hh:mm aa",
|
||||
"MM/dd/yyyy hh:mm aa",
|
||||
]}
|
||||
itemRenderer={(option) => (
|
||||
<Typography sx={{ fontFamily: "mono" }}>{option.label}</Typography>
|
||||
)}
|
||||
label="Display format"
|
||||
multiple={false}
|
||||
freeText
|
||||
clearable={false}
|
||||
searchable={false}
|
||||
value={config.format ?? DATE_TIME_FORMAT}
|
||||
onChange={handleChange("format")}
|
||||
TextFieldProps={{
|
||||
helperText: (
|
||||
<Link
|
||||
href={EXTERNAL_LINKS.dateFormat}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Date format reference
|
||||
<InlineOpenInNewIcon />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ISideDrawerFieldProps } from "../types";
|
||||
|
||||
import { Stack, Typography, Avatar } from "@mui/material";
|
||||
@@ -6,50 +6,47 @@ import { useFieldStyles } from "components/SideDrawer/Form/utils";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function User({ control, column }: ISideDrawerFieldProps) {
|
||||
export default function CreatedBy({ control, column }: ISideDrawerFieldProps) {
|
||||
const fieldClasses = useFieldStyles();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={column.key}
|
||||
render={({ field: { value } }) => {
|
||||
if (!value || !value.displayName || !value.timestamp)
|
||||
return <div className={fieldClasses.root} />;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
className={fieldClasses.root}
|
||||
style={{ alignItems: "flex-start" }}
|
||||
>
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
|
||||
/>
|
||||
const { table } = useProjectContext();
|
||||
const value = useWatch({
|
||||
control,
|
||||
name: table?.auditFieldCreatedBy || "_createdBy",
|
||||
});
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
style={{ whiteSpace: "normal" }}
|
||||
>
|
||||
{value.displayName} ({value.email})
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="textSecondary"
|
||||
component="div"
|
||||
>
|
||||
Created at {dateLabel}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
if (!value || !value.displayName || !value.timestamp)
|
||||
return <div className={fieldClasses.root} />;
|
||||
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
className={fieldClasses.root}
|
||||
style={{ alignItems: "flex-start" }}
|
||||
>
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
style={{ whiteSpace: "normal" }}
|
||||
>
|
||||
{value.displayName} ({value.email})
|
||||
<Typography variant="caption" color="textSecondary" component="div">
|
||||
Created at {dateLabel}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { IHeavyCellProps } from "../types";
|
||||
|
||||
import { Tooltip, Chip, Avatar } from "@mui/material";
|
||||
import { Tooltip, Stack, Avatar } from "@mui/material";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function CreatedBy({ row, column }: IHeavyCellProps) {
|
||||
const { table } = useProjectContext();
|
||||
const value = row[table?.auditFieldCreatedBy || "_createdBy"];
|
||||
|
||||
export default function User({ value }: IHeavyCellProps) {
|
||||
if (!value || !value.displayName || !value.timestamp) return null;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={`Created at ${dateLabel}`}>
|
||||
<Chip
|
||||
size="small"
|
||||
avatar={<Avatar alt="Avatar" src={value.photoURL} />}
|
||||
label={value.displayName}
|
||||
sx={{ mx: -0.25, height: 24 }}
|
||||
/>
|
||||
<Stack spacing={0.75} direction="row" alignItems="center">
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<span>{value.displayName}</span>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,19 +15,23 @@ const SideDrawerField = lazy(
|
||||
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-CreatedBy" */
|
||||
)
|
||||
);
|
||||
const Settings = lazy(
|
||||
() => import("./Settings" /* webpackChunkName: "Settings-CreatedBy" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.createdBy,
|
||||
name: "Created By",
|
||||
group: "Metadata",
|
||||
group: "Auditing",
|
||||
dataType:
|
||||
"{ displayName: string; email: string; emailVerified: boolean; isAnonymous: boolean; photoURL: string; uid: string; timestamp: firebase.firestore.Timestamp; }",
|
||||
initialValue: null,
|
||||
icon: <CreatedByIcon />,
|
||||
description:
|
||||
"When a user creates a row, automatically logs user information and timestamp. Read-only.",
|
||||
"Displays the user that created the row and timestamp. Read-only.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
|
||||
itemRenderer={(option) => (
|
||||
<Typography sx={{ fontFamily: "mono" }}>{option.label}</Typography>
|
||||
)}
|
||||
label="Format"
|
||||
label="Display format"
|
||||
multiple={false}
|
||||
freeText
|
||||
clearable={false}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
|
||||
itemRenderer={(option) => (
|
||||
<Typography sx={{ fontFamily: "mono" }}>{option.label}</Typography>
|
||||
)}
|
||||
label="Format"
|
||||
label="Display format"
|
||||
multiple={false}
|
||||
freeText
|
||||
clearable={false}
|
||||
|
||||
34
src/components/fields/UpdatedAt/SideDrawerField.tsx
Normal file
34
src/components/fields/UpdatedAt/SideDrawerField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ISideDrawerFieldProps } from "../types";
|
||||
|
||||
import { useFieldStyles } from "components/SideDrawer/Form/utils";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function UpdatedAt({ control, column }: ISideDrawerFieldProps) {
|
||||
const fieldClasses = useFieldStyles();
|
||||
|
||||
const { table } = useProjectContext();
|
||||
const value = useWatch({
|
||||
control,
|
||||
name: table?.auditFieldUpdatedBy || "_updatedBy",
|
||||
});
|
||||
|
||||
if (!value || !value.timestamp) return <div className={fieldClasses.root} />;
|
||||
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={fieldClasses.root}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{dateLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/fields/UpdatedAt/TableCell.tsx
Normal file
20
src/components/fields/UpdatedAt/TableCell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IHeavyCellProps } from "../types";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function UpdatedBy({ row, column }: IHeavyCellProps) {
|
||||
const { table } = useProjectContext();
|
||||
const value = row[table?.auditFieldUpdatedBy || "_updatedBy"];
|
||||
|
||||
if (!value || !value.timestamp) return null;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{dateLabel}</span>
|
||||
);
|
||||
}
|
||||
37
src/components/fields/UpdatedAt/index.tsx
Normal file
37
src/components/fields/UpdatedAt/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { lazy } from "react";
|
||||
import { IFieldConfig, FieldType } from "components/fields/types";
|
||||
import withHeavyCell from "../_withTableCell/withHeavyCell";
|
||||
|
||||
import UpdatedAtIcon from "assets/icons/UpdatedAt";
|
||||
import BasicCell from "../_BasicCell/BasicCellNull";
|
||||
import withSideDrawerEditor from "components/Table/editors/withSideDrawerEditor";
|
||||
|
||||
const TableCell = lazy(
|
||||
() => import("./TableCell" /* webpackChunkName: "TableCell-UpdatedAt" */)
|
||||
);
|
||||
const SideDrawerField = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-UpdatedAt" */
|
||||
)
|
||||
);
|
||||
const Settings = lazy(
|
||||
() =>
|
||||
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.updatedAt,
|
||||
name: "Updated At",
|
||||
group: "Auditing",
|
||||
dataType: "firebase.firestore.Timestamp",
|
||||
initialValue: null,
|
||||
icon: <UpdatedAtIcon />,
|
||||
description:
|
||||
"Displays the timestamp of the last update to the row. Read-only.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { ISideDrawerFieldProps } from "../types";
|
||||
|
||||
import { Stack, Typography, Avatar } from "@mui/material";
|
||||
@@ -6,50 +6,54 @@ import { useFieldStyles } from "components/SideDrawer/Form/utils";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function User({ control, column }: ISideDrawerFieldProps) {
|
||||
export default function UpdatedBy({ control, column }: ISideDrawerFieldProps) {
|
||||
const fieldClasses = useFieldStyles();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={column.key}
|
||||
render={({ field: { value } }) => {
|
||||
if (!value || !value.displayName || !value.timestamp)
|
||||
return <div className={fieldClasses.root} />;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
className={fieldClasses.root}
|
||||
style={{ alignItems: "flex-start" }}
|
||||
>
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
|
||||
/>
|
||||
const { table } = useProjectContext();
|
||||
const value = useWatch({
|
||||
control,
|
||||
name: table?.auditFieldUpdatedBy || "_updatedBy",
|
||||
});
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
style={{ whiteSpace: "normal" }}
|
||||
>
|
||||
{value.displayName} ({value.email})
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="textSecondary"
|
||||
component="div"
|
||||
>
|
||||
Updated field <code>{value.updatedField}</code> at {dateLabel}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
if (!value || !value.displayName || !value.timestamp)
|
||||
return <div className={fieldClasses.root} />;
|
||||
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
className={fieldClasses.root}
|
||||
style={{ alignItems: "flex-start" }}
|
||||
>
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
sx={{ width: 32, height: 32, ml: -0.5, mr: 1.5, my: 0.5 }}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
style={{ whiteSpace: "normal" }}
|
||||
>
|
||||
{value.displayName} ({value.email})
|
||||
<Typography variant="caption" color="textSecondary" component="div">
|
||||
Updated
|
||||
{value.updatedField && (
|
||||
<>
|
||||
{" "}
|
||||
field <code>{value.updatedField}</code>
|
||||
</>
|
||||
)}{" "}
|
||||
at {dateLabel}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
import { IHeavyCellProps } from "../types";
|
||||
|
||||
import { Tooltip, Chip, Avatar } from "@mui/material";
|
||||
import { Tooltip, Stack, Avatar } from "@mui/material";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
import { useProjectContext } from "contexts/ProjectContext";
|
||||
|
||||
export default function UpdatedBy({ row, column }: IHeavyCellProps) {
|
||||
const { table } = useProjectContext();
|
||||
const value = row[table?.auditFieldUpdatedBy || "_updatedBy"];
|
||||
|
||||
export default function User({ value }: IHeavyCellProps) {
|
||||
if (!value || !value.displayName || !value.timestamp) return null;
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
Updated field <code>{value.updatedField}</code>
|
||||
Updated
|
||||
{value.updatedField && (
|
||||
<>
|
||||
{" "}
|
||||
field <code>{value.updatedField}</code>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
at {dateLabel}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
avatar={<Avatar alt="Avatar" src={value.photoURL} />}
|
||||
label={value.displayName}
|
||||
sx={{ mx: -0.25, height: 24 }}
|
||||
/>
|
||||
<Stack spacing={0.75} direction="row" alignItems="center">
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<span>{value.displayName}</span>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,19 +15,24 @@ const SideDrawerField = lazy(
|
||||
"./SideDrawerField" /* webpackChunkName: "SideDrawerField-UpdatedBy" */
|
||||
)
|
||||
);
|
||||
const Settings = lazy(
|
||||
() =>
|
||||
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.updatedBy,
|
||||
name: "Updated By",
|
||||
group: "Metadata",
|
||||
group: "Auditing",
|
||||
dataType:
|
||||
"{ displayName: string; email: string; emailVerified: boolean; isAnonymous: boolean; photoURL: string; uid: string; timestamp: firebase.firestore.Timestamp; updatedField?: string; }",
|
||||
initialValue: null,
|
||||
icon: <UpdatedByIcon />,
|
||||
description:
|
||||
"When a user updates a row, automatically logs user information, timestamp, and updated field key. Read-only.",
|
||||
"Displays the user that last updated the row, timestamp, and updated field key. Read-only.",
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function User({ control, column }: ISideDrawerFieldProps) {
|
||||
value.timestamp.toDate
|
||||
? value.timestamp.toDate()
|
||||
: value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
)
|
||||
: null;
|
||||
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { IHeavyCellProps } from "../types";
|
||||
|
||||
import { Tooltip, Chip, Avatar } from "@mui/material";
|
||||
import { Tooltip, Stack, Avatar } from "@mui/material";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
|
||||
export default function User({ value }: IHeavyCellProps) {
|
||||
export default function User({ value, column }: IHeavyCellProps) {
|
||||
if (!value || !value.displayName) return null;
|
||||
|
||||
const chip = (
|
||||
<Chip
|
||||
size="small"
|
||||
avatar={<Avatar alt="Avatar" src={value.photoURL} />}
|
||||
label={value.displayName}
|
||||
sx={{ mx: -0.25, height: 24 }}
|
||||
/>
|
||||
<Stack spacing={0.75} direction="row" alignItems="center">
|
||||
<Avatar
|
||||
alt="Avatar"
|
||||
src={value.photoURL}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<span>{value.displayName}</span>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!value.timestamp) return chip;
|
||||
|
||||
const dateLabel = format(
|
||||
value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp,
|
||||
DATE_TIME_FORMAT
|
||||
column.config?.format || DATE_TIME_FORMAT
|
||||
);
|
||||
|
||||
return <Tooltip title={dateLabel}>{chip}</Tooltip>;
|
||||
|
||||
@@ -13,6 +13,10 @@ const SideDrawerField = lazy(
|
||||
() =>
|
||||
import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */)
|
||||
);
|
||||
const Settings = lazy(
|
||||
() =>
|
||||
import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */)
|
||||
);
|
||||
|
||||
export const config: IFieldConfig = {
|
||||
type: FieldType.user,
|
||||
@@ -26,5 +30,6 @@ export const config: IFieldConfig = {
|
||||
TableCell: withHeavyCell(BasicCell, TableCell),
|
||||
TableEditor: withSideDrawerEditor(TableCell),
|
||||
SideDrawerField,
|
||||
settings: Settings,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -32,9 +32,11 @@ import Code from "./Code";
|
||||
import Action from "./Action";
|
||||
import Derivative from "./Derivative";
|
||||
import Aggregate from "./Aggregate";
|
||||
import User from "./User";
|
||||
import UpdatedBy from "./UpdatedBy";
|
||||
import CreatedBy from "./CreatedBy";
|
||||
import UpdatedBy from "./UpdatedBy";
|
||||
import CreatedAt from "./CreatedAt";
|
||||
import UpdatedAt from "./UpdatedAt";
|
||||
import User from "./User";
|
||||
import Id from "./Id";
|
||||
import Status from "./Status";
|
||||
|
||||
@@ -76,10 +78,13 @@ export const FIELDS: IFieldConfig[] = [
|
||||
Derivative,
|
||||
Aggregate,
|
||||
Status,
|
||||
// AUDITING
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
// METADATA
|
||||
User,
|
||||
UpdatedBy,
|
||||
CreatedBy,
|
||||
Id,
|
||||
];
|
||||
|
||||
|
||||
@@ -36,10 +36,13 @@ export enum FieldType {
|
||||
derivative = "DERIVATIVE",
|
||||
aggregate = "AGGREGATE",
|
||||
status = "STATUS",
|
||||
// AUDIT
|
||||
createdBy = "CREATED_BY",
|
||||
updatedBy = "UPDATED_BY",
|
||||
createdAt = "CREATED_AT",
|
||||
updatedAt = "UPDATED_AT",
|
||||
// METADATA
|
||||
user = "USER",
|
||||
updatedBy = "UPDATED_BY",
|
||||
createdBy = "CREATED_BY",
|
||||
id = "ID",
|
||||
last = "LAST",
|
||||
}
|
||||
|
||||
@@ -28,10 +28,14 @@ export type Table = {
|
||||
description: string;
|
||||
section: string;
|
||||
tableType: "primaryCollection" | "collectionGroup";
|
||||
audit?: boolean;
|
||||
auditFieldCreatedBy?: string;
|
||||
auditFieldUpdatedBy?: string;
|
||||
};
|
||||
|
||||
interface IProjectContext {
|
||||
tables: Table[];
|
||||
table: Table;
|
||||
roles: string[];
|
||||
tableState: TableState;
|
||||
tableActions: TableActions;
|
||||
@@ -57,11 +61,12 @@ interface IProjectContext {
|
||||
}) => void;
|
||||
updateTable: (data: {
|
||||
id: string;
|
||||
collection: string;
|
||||
name: string;
|
||||
description: string;
|
||||
roles: string[];
|
||||
section: string;
|
||||
name?: string;
|
||||
collection?: string;
|
||||
section?: string;
|
||||
description?: string;
|
||||
roles?: string[];
|
||||
[key: string]: any;
|
||||
}) => Promise<any>;
|
||||
deleteTable: (id: string) => void;
|
||||
};
|
||||
@@ -92,6 +97,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
|
||||
const { tableState, tableActions } = useTable();
|
||||
const [tables, setTables] = useState<IProjectContext["tables"]>();
|
||||
const [settings, settingsActions] = useSettings();
|
||||
const table = _find(tables, (table) => table.id === tableState.config.id);
|
||||
|
||||
useEffect(() => {
|
||||
const { tables } = settings;
|
||||
@@ -153,19 +159,14 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
|
||||
.filter((column) => column.config.required)
|
||||
.map((column) => column.key);
|
||||
|
||||
const createdByColumn = _find(tableState.columns, [
|
||||
"type",
|
||||
FieldType.createdBy,
|
||||
]);
|
||||
if (createdByColumn)
|
||||
initialData[createdByColumn.key] = rowyUser(currentUser!);
|
||||
|
||||
const updatedByColumn = _find(tableState.columns, [
|
||||
"type",
|
||||
FieldType.updatedBy,
|
||||
]);
|
||||
if (updatedByColumn)
|
||||
initialData[updatedByColumn.key] = rowyUser(currentUser!);
|
||||
if (table?.audit !== false) {
|
||||
initialData[table?.auditFieldCreatedBy || "_createdBy"] = rowyUser(
|
||||
currentUser!
|
||||
);
|
||||
initialData[table?.auditFieldUpdatedBy || "_updatedBy"] = rowyUser(
|
||||
currentUser!
|
||||
);
|
||||
}
|
||||
|
||||
tableActions.row.add(
|
||||
{ ...valuesFromFilter, ...initialData, ...data },
|
||||
@@ -183,14 +184,12 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
|
||||
|
||||
const update = { [fieldName]: value };
|
||||
|
||||
const updatedByColumn = _find(tableState.columns, [
|
||||
"type",
|
||||
FieldType.updatedBy,
|
||||
]);
|
||||
if (updatedByColumn)
|
||||
update[updatedByColumn.key] = rowyUser(currentUser!, {
|
||||
updatedField: fieldName,
|
||||
});
|
||||
if (table?.audit !== false) {
|
||||
update[table?.auditFieldUpdatedBy || "_updatedBy"] = rowyUser(
|
||||
currentUser!,
|
||||
{ updatedField: fieldName }
|
||||
);
|
||||
}
|
||||
|
||||
tableActions.row.update(
|
||||
ref,
|
||||
@@ -254,6 +253,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => {
|
||||
settingsActions,
|
||||
roles,
|
||||
tables,
|
||||
table,
|
||||
dataGridRef,
|
||||
sideDrawerRef,
|
||||
columnMenuRef,
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function useSettings() {
|
||||
const tableSchemaDocRef = db.doc(tableSchemaPath);
|
||||
|
||||
// Get columns from schemaSource if provided
|
||||
let columns: Record<string, any> = [];
|
||||
let columns: Record<string, any> = {};
|
||||
if (schemaSource) {
|
||||
const schemaSourcePath = `${
|
||||
tableSettings.tableType !== "collectionGroup"
|
||||
@@ -57,14 +57,18 @@ export default function useSettings() {
|
||||
}
|
||||
// Add columns from `_initialColumns`
|
||||
for (const [type, checked] of Object.entries(data._initialColumns)) {
|
||||
if (checked && !columns.some((column) => column.type === type))
|
||||
columns.push({
|
||||
if (
|
||||
checked &&
|
||||
!Object.values(columns).some((column) => column.type === type)
|
||||
)
|
||||
columns["_" + _camelCase(type)] = {
|
||||
type,
|
||||
name: getFieldProp("name", type as FieldType),
|
||||
key: "_" + _camelCase(type),
|
||||
fieldName: "_" + _camelCase(type),
|
||||
config: {},
|
||||
});
|
||||
index: Object.values(columns).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Appends table to settings doc
|
||||
@@ -83,10 +87,12 @@ export default function useSettings() {
|
||||
|
||||
const updateTable = async (data: {
|
||||
id: string;
|
||||
name: string;
|
||||
collection: string;
|
||||
description: string;
|
||||
roles: string[];
|
||||
name?: string;
|
||||
collection?: string;
|
||||
section?: string;
|
||||
description?: string;
|
||||
roles?: string[];
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const { tables } = settingsState;
|
||||
const newTables = Array.isArray(tables) ? [...tables] : [];
|
||||
|
||||
@@ -2362,6 +2362,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@mdi/js/-/js-5.9.55.tgz#8f5bc4d924c23f30dab20545ddc768e778bbc882"
|
||||
integrity sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A==
|
||||
|
||||
"@mdi/js@^6.2.95":
|
||||
version "6.2.95"
|
||||
resolved "https://registry.yarnpkg.com/@mdi/js/-/js-6.2.95.tgz#decf0f86035990248f25b0a4e246a7d152211273"
|
||||
integrity sha512-fbD22sEBathqVSQWcxshEtzhhRNFmMnV64z6T7DClRbQ9N5axorykt3Suv2zPzLDyiqH7UhNRu0VPvPCPDNpnQ==
|
||||
|
||||
"@monaco-editor/loader@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.2.0.tgz#373fad69973384624e3d9b60eefd786461a76acd"
|
||||
|
||||
Reference in New Issue
Block a user