@firecms/entity_history 3.0.0-canary.237

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ import { FireCMSPlugin, User } from "@firecms/core";
2
+ /**
3
+ *
4
+ */
5
+ export declare function useEntityHistoryPlugin(props?: EntityHistoryPluginProps): FireCMSPlugin<any, any, any, EntityHistoryPluginProps>;
6
+ export type EntityHistoryPluginProps = {
7
+ /**
8
+ * If true, the history view will be enabled to all collections by default.
9
+ * Each collection can override this value by setting the `history` property.
10
+ */
11
+ defaultEnabled?: boolean;
12
+ /**
13
+ * Function to get the user object from the uid.
14
+ * @param uid
15
+ */
16
+ getUser?: (uid: string) => User | null;
17
+ };
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@firecms/entity_history",
3
+ "type": "module",
4
+ "version": "3.0.0-canary.237",
5
+ "access": "public",
6
+ "main": "./dist/index.umd.js",
7
+ "module": "./dist/index.es.js",
8
+ "types": "./dist/index.d.ts",
9
+ "source": "src/index.ts",
10
+ "dependencies": {
11
+ "@firecms/core": "^3.0.0-canary.237",
12
+ "@firecms/formex": "^3.0.0-canary.237",
13
+ "@firecms/ui": "^3.0.0-canary.237"
14
+ },
15
+ "peerDependencies": {
16
+ "react": ">=18.0.0",
17
+ "react-dom": ">=18.0.0"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "import": "./dist/index.es.js",
22
+ "require": "./dist/index.umd.js",
23
+ "types": "./dist/index.d.ts"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "scripts": {
28
+ "dev": "vite",
29
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
30
+ "prepublishOnly": "run-s build",
31
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
32
+ "test": "jest"
33
+ },
34
+ "browserslist": {
35
+ "production": [
36
+ ">0.2%",
37
+ "not dead",
38
+ "not op_mini all"
39
+ ],
40
+ "development": [
41
+ "last 1 chrome version",
42
+ "last 1 firefox version",
43
+ "last 1 safari version"
44
+ ]
45
+ },
46
+ "devDependencies": {
47
+ "@jest/globals": "^29.7.0",
48
+ "@testing-library/jest-dom": "^6.6.3",
49
+ "@types/jest": "^29.5.14",
50
+ "@types/react": "^18.3.18",
51
+ "@types/react-dom": "^18.3.0",
52
+ "@vitejs/plugin-react": "^4.3.4",
53
+ "babel-jest": "^29.7.0",
54
+ "babel-plugin-react-compiler": "beta",
55
+ "eslint-plugin-react-compiler": "beta",
56
+ "jest": "^29.7.0",
57
+ "ts-jest": "^29.2.5",
58
+ "typescript": "^5.7.3",
59
+ "vite": "^5.4.14"
60
+ },
61
+ "jest": {
62
+ "transform": {
63
+ "^.+\\.tsx?$": "ts-jest"
64
+ },
65
+ "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
66
+ "moduleFileExtensions": [
67
+ "ts",
68
+ "tsx",
69
+ "js",
70
+ "jsx",
71
+ "json",
72
+ "node"
73
+ ],
74
+ "moduleNameMapper": {
75
+ "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
76
+ }
77
+ },
78
+ "files": [
79
+ "dist",
80
+ "src"
81
+ ],
82
+ "publishConfig": {
83
+ "access": "public"
84
+ },
85
+ "gitHead": "20e10b4e0cd4b6a2f2831b219ce43a6d09047a54"
86
+ }
@@ -0,0 +1,40 @@
1
+ import React, { PropsWithChildren, useContext } from "react";
2
+ import equal from "react-fast-compare"
3
+
4
+ import { User } from "@firecms/core";
5
+
6
+ export type HistoryConfigController = {
7
+ /**
8
+ * Function to get a user by uid.
9
+ * @param uid
10
+ */
11
+ getUser?: (uid: string) => User | null;
12
+ }
13
+
14
+ export const HistoryControllerContext = React.createContext<HistoryConfigController>({} as any);
15
+ export const useHistoryController = (): HistoryConfigController => useContext(HistoryControllerContext);
16
+
17
+
18
+ export interface HistoryControllerProviderProps {
19
+
20
+ getUser?: (uid: string) => User | null;
21
+
22
+ }
23
+
24
+ export const HistoryControllerProvider = React.memo(
25
+ function HistoryControllerProvider({
26
+ children,
27
+ getUser,
28
+ }: PropsWithChildren<HistoryControllerProviderProps>) {
29
+
30
+ return (
31
+ <HistoryControllerContext.Provider
32
+ value={{
33
+ getUser,
34
+ }}>
35
+
36
+ {children}
37
+
38
+ </HistoryControllerContext.Provider>
39
+ );
40
+ }, equal);
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+
3
+ import { Chip, cls, defaultBorderMixin, IconButton, KeyboardTabIcon, Tooltip, Typography } from "@firecms/ui";
4
+ import {
5
+ Entity,
6
+ EntityCollection,
7
+ getPropertyInPath,
8
+ getValueInPath,
9
+ PreviewSize,
10
+ PropertyPreview,
11
+ resolveCollection,
12
+ ResolvedProperty,
13
+ SkeletonPropertyComponent,
14
+ useAuthController,
15
+ useCustomizationController,
16
+ useNavigationController,
17
+ useSideEntityController
18
+ } from "@firecms/core";
19
+ import { useHistoryController } from "../HistoryControllerProvider";
20
+ import { UserChip } from "./UserChip";
21
+
22
+ export type EntityPreviewProps = {
23
+ size: PreviewSize,
24
+ actions?: React.ReactNode,
25
+ collection?: EntityCollection,
26
+ hover?: boolean;
27
+ previewKeys?: string[],
28
+ disabled?: boolean,
29
+ entity: Entity<any>,
30
+ onClick?: (e: React.SyntheticEvent) => void;
31
+ };
32
+
33
+ /**
34
+ * This view is used to display a preview of an entity.
35
+ * It is used by default in reference fields and whenever a reference is displayed.
36
+ */
37
+ export function EntityHistoryEntry({
38
+ actions,
39
+ disabled,
40
+ hover,
41
+ collection: collectionProp,
42
+ previewKeys,
43
+ onClick,
44
+ size,
45
+ entity
46
+ }: EntityPreviewProps) {
47
+
48
+ const authController = useAuthController();
49
+ const customizationController = useCustomizationController();
50
+
51
+ const navigationController = useNavigationController();
52
+ const sideEntityController = useSideEntityController();
53
+
54
+ const collection = collectionProp ?? navigationController.getCollection(entity.path);
55
+ const updatedOn = entity.values?.["__metadata"]?.["updated_on"];
56
+ if (!collection) {
57
+ throw Error(`Couldn't find the corresponding collection view for the path: ${entity.path}`);
58
+ }
59
+
60
+ const updatedBy = entity.values?.["__metadata"]?.["updated_by"];
61
+ const { getUser } = useHistoryController();
62
+ const user = getUser?.(updatedBy);
63
+
64
+ const resolvedCollection = React.useMemo(() => resolveCollection({
65
+ collection,
66
+ path: entity.path,
67
+ values: entity.values,
68
+ propertyConfigs: customizationController.propertyConfigs,
69
+ authController
70
+ }), [collection]);
71
+
72
+ return <div className={"w-full flex flex-col gap-2 mt-4"}>
73
+ <div className={"ml-4 flex items-center gap-4"}>
74
+ <Typography variant={"body2"} color={"secondary"}>{updatedOn.toLocaleString()}</Typography>
75
+ {!user && updatedBy && <Chip size={"small"}>{updatedBy}</Chip>}
76
+ {user && <UserChip user={user}/>}
77
+ </div>
78
+ <div
79
+ className={cls(
80
+ "bg-white dark:bg-surface-900",
81
+ "min-h-[42px]",
82
+ "w-full",
83
+ "items-center",
84
+ hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
85
+ size === "small" ? "p-1" : "px-2 py-1",
86
+ "flex border rounded-lg",
87
+ onClick ? "cursor-pointer" : "",
88
+ defaultBorderMixin
89
+ )}>
90
+
91
+
92
+ {actions}
93
+
94
+ {entity &&
95
+ <Tooltip title={"See details for this revision"}
96
+ className={"my-2 grow-0 shrink-0 self-start"}>
97
+ <IconButton
98
+ color={"inherit"}
99
+ className={""}
100
+ onClick={(e) => {
101
+
102
+ sideEntityController.open({
103
+ entityId: entity.id,
104
+ path: entity.path,
105
+ allowFullScreen: false,
106
+ collection: {
107
+ ...collection,
108
+ subcollections: undefined,
109
+ entityViews: undefined,
110
+ permissions: {
111
+ create: false,
112
+ delete: false,
113
+ edit: false,
114
+ read: true
115
+ }
116
+ },
117
+ updateUrl: true
118
+ });
119
+ }}>
120
+ <KeyboardTabIcon/>
121
+ </IconButton>
122
+ </Tooltip>}
123
+
124
+ <div className={"flex flex-col grow w-full m-1 shrink min-w-0"}>
125
+
126
+ {previewKeys && previewKeys.map((key) => {
127
+ const childProperty = getPropertyInPath(resolvedCollection.properties, key);
128
+ if (!childProperty) return null;
129
+
130
+ const valueInPath = getValueInPath(entity.values, key);
131
+ return (
132
+ <div key={"ref_prev_" + key}
133
+ className="flex w-full my-1 items-center">
134
+ <Typography variant={"caption"}
135
+ color={"secondary"}
136
+ className="min-w-[140px] md:min-w-[200px] w-1/5 pr-8 overflow-hidden text-ellipsis text-right">
137
+ {key}
138
+ </Typography>
139
+ <div className="w-4/5">
140
+ {
141
+ entity
142
+ ? <PropertyPreview
143
+ propertyKey={key as string}
144
+ value={valueInPath}
145
+ property={childProperty as ResolvedProperty}
146
+ size={"small"}/>
147
+ : <SkeletonPropertyComponent
148
+ property={childProperty as ResolvedProperty}
149
+ size={"small"}/>
150
+ }
151
+ </div>
152
+ </div>
153
+ );
154
+ })}
155
+
156
+ </div>
157
+
158
+ </div>
159
+ </div>
160
+ }
161
+
@@ -0,0 +1,222 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ ConfirmationDialog,
4
+ Entity,
5
+ EntityCustomViewParams,
6
+ useAuthController,
7
+ useDataSource,
8
+ useSnackbarController
9
+ } from "@firecms/core";
10
+ import { cls, HistoryIcon, IconButton, Label, Tooltip, Typography } from "@firecms/ui";
11
+ import { EntityHistoryEntry } from "./EntityHistoryEntry";
12
+
13
+ export function EntityHistoryView({
14
+ entity,
15
+ collection,
16
+ formContext
17
+ }: EntityCustomViewParams) {
18
+
19
+ const authController = useAuthController();
20
+ const snackbarController = useSnackbarController();
21
+ const dirty = formContext?.formex.dirty;
22
+
23
+ const dataSource = useDataSource();
24
+ const pathAndId = entity ? entity?.path + "/" + entity?.id : undefined;
25
+
26
+ const [revertVersion, setRevertVersion] = useState<Entity | undefined>(undefined);
27
+ const [revisions, setRevisions] = useState<Entity[]>([]);
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [hasMore, setHasMore] = useState(true);
30
+
31
+ const PAGE_SIZE = 5;
32
+ const [limit, setLimit] = useState(PAGE_SIZE);
33
+
34
+ const containerRef = useRef<HTMLDivElement>(null);
35
+ const observerRef = useRef<IntersectionObserver | null>(null);
36
+ const loadMoreRef = useRef<HTMLDivElement>(null);
37
+
38
+ // Load revisions with the current limit
39
+ useEffect(() => {
40
+ if (!pathAndId) return;
41
+
42
+ setIsLoading(true); // Set loading true when fetching starts
43
+ const listener = dataSource.listenCollection?.({
44
+ path: pathAndId + "/__history",
45
+ order: "desc",
46
+ orderBy: "__metadata.updated_on",
47
+ limit: limit,
48
+ startAfter: undefined,
49
+ onUpdate: (entities) => {
50
+ setRevisions(entities);
51
+ setHasMore(entities.length === limit && entities.length >= PAGE_SIZE); // Ensure we fetched a full page to consider hasMore
52
+ setIsLoading(false);
53
+ },
54
+ onError: (error) => {
55
+ console.error("Error fetching history:", error);
56
+ setIsLoading(false);
57
+ setHasMore(false); // Stop trying if there's an error
58
+ }
59
+ });
60
+ return () => {
61
+ if (typeof listener === "function") {
62
+ listener();
63
+ }
64
+ };
65
+ }, [pathAndId, limit, dataSource]);
66
+
67
+ // Setup intersection observer for infinite scroll
68
+ useEffect(() => {
69
+ const currentContainer = containerRef.current;
70
+ const currentLoadMore = loadMoreRef.current;
71
+
72
+ // Conditions for active observation
73
+ if (!currentContainer || !currentLoadMore || !hasMore || isLoading) {
74
+ // If we shouldn't be observing, ensure any existing observer is disconnected
75
+ if (observerRef.current) {
76
+ observerRef.current.disconnect();
77
+ observerRef.current = null;
78
+ }
79
+ return;
80
+ }
81
+
82
+ // Options for the IntersectionObserver
83
+ const options = {
84
+ root: currentContainer,
85
+ rootMargin: "0px 0px 200px 0px", // Trigger 200px before the sentinel is at the bottom edge
86
+ threshold: 0.01 // Trigger if even a small part is visible within the rootMargin
87
+ };
88
+
89
+ // The callback for when the sentinel's intersection state changes
90
+ const handleObserver = (entries: IntersectionObserverEntry[]) => {
91
+ const target = entries[0];
92
+ if (target.isIntersecting && hasMore && !isLoading) {
93
+ // No need to setIsLoading(true) here, it's done in the data fetching useEffect
94
+ setLimit(prev => prev + PAGE_SIZE);
95
+ }
96
+ };
97
+
98
+ const observer = new IntersectionObserver(handleObserver, options);
99
+ observer.observe(currentLoadMore);
100
+ observerRef.current = observer; // Store the new observer
101
+
102
+ // Cleanup function for this effect instance
103
+ return () => {
104
+ observer.disconnect(); // Disconnect the observer created in *this* effect run
105
+ if (observerRef.current === observer) {
106
+ observerRef.current = null;
107
+ }
108
+ };
109
+ // Re-run if hasMore, isLoading changes, or if revisions.length changes (which might make loadMoreRef available/unavailable)
110
+ }, [hasMore, isLoading, revisions.length]);
111
+
112
+ if (!entity) {
113
+ return <div className="flex items-center justify-center h-full">
114
+ <Label>History is only available for existing entities</Label>
115
+ </div>
116
+ }
117
+
118
+ function doRevert(revertVersion: Entity) {
119
+ const revertValues = {
120
+ ...revertVersion.values,
121
+ __metadata: {
122
+ ...revertVersion.values?.["__metadata"],
123
+ reverted: true,
124
+ updated_on: new Date(),
125
+ updated_by: authController.user?.uid ?? null,
126
+ }
127
+ };
128
+ return dataSource.saveEntity({
129
+ path: revertVersion.path,
130
+ entityId: revertVersion.id,
131
+ values: revertValues,
132
+ collection,
133
+ status: "existing"
134
+ }).then(() => {
135
+ formContext.formex.resetForm({
136
+ values: revertVersion.values
137
+ });
138
+ setRevertVersion(undefined);
139
+ snackbarController.open({
140
+ message: "Reverted version",
141
+ type: "info"
142
+ });
143
+ }
144
+ ).catch((error) => {
145
+ console.error("Error reverting entity:", error);
146
+ snackbarController.open({
147
+ message: "Error reverting entity",
148
+ type: "error"
149
+ });
150
+ });
151
+
152
+ }
153
+
154
+ return <div
155
+ ref={containerRef}
156
+ className={cls("relative flex-1 h-full overflow-auto w-full flex flex-col gap-4 p-8")}>
157
+ <div className="flex flex-col gap-2 max-w-6xl mx-auto w-full">
158
+
159
+ <Typography variant={"h5"} className={"mt-24 ml-4"}>
160
+ History
161
+ </Typography>
162
+
163
+ {revisions.length === 0 && <>
164
+ <Label className={"ml-4 mt-8"}>
165
+ No history available
166
+ </Label>
167
+ <Typography variant={"caption"} className={"ml-4"}>
168
+ When you save an entity, a new version is created and stored in the history.
169
+ </Typography>
170
+ </>}
171
+
172
+ {revisions.map((revision, index) => {
173
+ const previewKeys = revision.values?.["__metadata"]?.["changed_fields"];
174
+ return <div key={index} className="flex flex-cols gap-2 w-full">
175
+ <EntityHistoryEntry size={"large"}
176
+ entity={revision}
177
+ collection={collection}
178
+ previewKeys={previewKeys}
179
+ actions={
180
+ <Tooltip title={"Revert to this version"}
181
+ className={"m-2 grow-0 self-start"}>
182
+ <IconButton
183
+ onClick={() => {
184
+ if (dirty) {
185
+ snackbarController.open({
186
+ message: "Please save or discard your changes before reverting",
187
+ type: "warning"
188
+ });
189
+ } else {
190
+ setRevertVersion(revision);
191
+ }
192
+ }}>
193
+ <HistoryIcon/>
194
+ </IconButton>
195
+ </Tooltip>}
196
+ />
197
+ </div>
198
+ })}
199
+
200
+ {/* Load more sentinel element */}
201
+ {revisions.length > 0 && (
202
+ <div
203
+ ref={loadMoreRef}
204
+ className="py-4 text-center"
205
+ >
206
+ {isLoading && <Label>Loading more...</Label>}
207
+ {!hasMore && revisions.length > PAGE_SIZE && <Label>No more history available</Label>}
208
+ </div>
209
+ )}
210
+ </div>
211
+
212
+ <ConfirmationDialog open={Boolean(revertVersion)}
213
+ onAccept={function (): void {
214
+ if (!revertVersion) return;
215
+ doRevert(revertVersion);
216
+ }}
217
+ onCancel={function (): void {
218
+ setRevertVersion(undefined);
219
+ }}
220
+ title={<Typography variant={"subtitle2"}>Revert data to this version?</Typography>}/>
221
+ </div>
222
+ }
@@ -0,0 +1,15 @@
1
+ import { User } from "@firecms/core";
2
+ import { Chip, Tooltip } from "@firecms/ui";
3
+
4
+ export function UserChip({ user }: { user: User }) {
5
+ return (
6
+ <Tooltip title={user.email ?? user.uid}>
7
+ <Chip size={"small"} className={"flex items-center"}>
8
+ {user.photoURL && <img
9
+ className={"rounded-full w-6 h-6 mr-2"}
10
+ src={user.photoURL} alt={user.displayName ?? "User picture"}/>}
11
+ <span>{user.displayName ?? user.email ?? user.uid}</span>
12
+ </Chip>
13
+ </Tooltip>
14
+ );
15
+ }
@@ -0,0 +1,99 @@
1
+ import { EntityCallbacks } from "@firecms/core";
2
+ import equal from "react-fast-compare"
3
+
4
+ export const entityHistoryCallbacks: EntityCallbacks = {
5
+ onSaveSuccess: async (props) => {
6
+
7
+ const changedFields = props.previousValues ? findChangedFields(props.previousValues, props.values) : null;
8
+ const uid = props.context.authController.user?.uid;
9
+ props.context.dataSource.saveEntity({
10
+ path: props.path + "/" + props.entityId + "/__history",
11
+ values: {
12
+ ...props.values,
13
+ __metadata: {
14
+ changed_fields: changedFields,
15
+ updated_on: new Date(),
16
+ updated_by: uid,
17
+ }
18
+ },
19
+ status: "new"
20
+ }).then(() => {
21
+ console.debug("History saved for", props.path, props.entityId);
22
+ });
23
+ }
24
+ }
25
+
26
+ function findChangedFields<M extends object>(oldValues: M, newValues: M, prefix: string = ""): string[] {
27
+ const changedFields: string[] = [];
28
+
29
+ // Handle null/undefined cases
30
+ if (equal(oldValues, newValues)) return changedFields;
31
+ if (!oldValues || !newValues) return [prefix || "."];
32
+
33
+ // Get all unique keys from both objects
34
+ const allKeys = new Set([
35
+ ...Object.keys(oldValues),
36
+ ...Object.keys(newValues)
37
+ ]);
38
+
39
+ for (const key of allKeys) {
40
+ const oldValue = oldValues[key as keyof M];
41
+ const newValue = newValues[key as keyof M];
42
+ const currentPath = prefix ? `${prefix}.${key}` : key;
43
+
44
+ // If key exists only in one object
45
+ if ((key in oldValues) !== (key in newValues)) {
46
+ changedFields.push(currentPath);
47
+ continue;
48
+ }
49
+
50
+ // If values are identical (deep equality)
51
+ if (equal(oldValue, newValue)) continue;
52
+
53
+ // Handle arrays
54
+ if (Array.isArray(oldValue) && Array.isArray(newValue)) {
55
+ if (oldValue.length !== newValue.length) {
56
+ changedFields.push(currentPath);
57
+ } else {
58
+ // Check if any array element changed
59
+ for (let i = 0; i < oldValue.length; i++) {
60
+ if (
61
+ typeof oldValue[i] === "object" && oldValue[i] !== null &&
62
+ typeof newValue[i] === "object" && newValue[i] !== null
63
+ ) {
64
+ const nestedChanges = findChangedFields(
65
+ oldValue[i] as object,
66
+ newValue[i] as object,
67
+ `${currentPath}[${i}]`
68
+ );
69
+ if (nestedChanges.length > 0) {
70
+ changedFields.push(currentPath);
71
+ break;
72
+ }
73
+ } else if (!equal(oldValue[i], newValue[i])) {
74
+ changedFields.push(currentPath);
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ }
80
+ // Handle nested objects
81
+ else if (
82
+ typeof oldValue === "object" && oldValue !== null &&
83
+ typeof newValue === "object" && newValue !== null
84
+ ) {
85
+ const nestedChanges = findChangedFields(
86
+ oldValue as object,
87
+ newValue as object,
88
+ currentPath
89
+ );
90
+ changedFields.push(...nestedChanges);
91
+ }
92
+ // Handle primitives
93
+ else {
94
+ changedFields.push(currentPath);
95
+ }
96
+ }
97
+
98
+ return changedFields;
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./useEntityHistoryPlugin";
2
+ export * from "./HistoryControllerProvider";