Skip to content

Commit cc164e6

Browse files
committed
feat: Implement save conflict detection and resolution with a diff viewer for document edits.
1 parent 2ef9493 commit cc164e6

3 files changed

Lines changed: 405 additions & 268 deletions

File tree

frontend/components/DocumentDetailView.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useTheme } from '../contexts/ThemeContext';
33
import { Button, CircularProgress } from '@mui/material';
44
import MonacoEditor from '@monaco-editor/react';
55
import { updateDocument, getSingleDocument } from '../services/dbService';
6+
import { isEqual, omit } from 'lodash';
7+
import SaveConflictDialog from './SaveConflictDialog';
68

79

810
interface DocumentEditViewProps {
@@ -26,17 +28,47 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
2628
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
2729
const { theme } = useTheme();
2830
const [isSaving, setIsSaving] = useState(false);
31+
const [isConflictDialogOpen, setIsConflictDialogOpen] = useState(false);
32+
const [conflictServerDocStr, setConflictServerDocStr] = useState<string>('');
2933

3034
useImperativeHandle(ref, () => ({
3135
getCurrentValue: () => jsonValue,
3236
setCurrentValue: (val: string) => setJsonValue(val)
3337
}));
3438

35-
const handleSave = async () => {
39+
const handleSave = async (forceSave = false) => {
3640
setIsSaving(true);
3741
try {
3842
const parsed = JSON.parse(jsonValue);
3943
if (!accountId || !databaseName || !collection || !docId) throw new Error('Missing DB info');
44+
45+
if (!forceSave) {
46+
// Fetch the latest document from DB
47+
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);
48+
49+
// Compare with the original document prop
50+
const ignoredKeys = ['_id', 'datetime_creation', 'datetime_last_modified'];
51+
const oldWithoutIgnored = omit(document, ignoredKeys);
52+
const newWithoutIgnored = omit(refreshed, ignoredKeys);
53+
54+
if (!isEqual(oldWithoutIgnored, newWithoutIgnored)) {
55+
// Sync ignored fields to match user's expected view without highlighting them
56+
const displayServerDoc = { ...refreshed };
57+
ignoredKeys.forEach(key => {
58+
if (key in parsed) {
59+
displayServerDoc[key] = parsed[key];
60+
} else {
61+
delete displayServerDoc[key];
62+
}
63+
});
64+
65+
setConflictServerDocStr(JSON.stringify(displayServerDoc, null, 2));
66+
setIsConflictDialogOpen(true);
67+
setIsSaving(false);
68+
return;
69+
}
70+
}
71+
4072
await updateDocument(accountId, databaseName, collection, docId, parsed);
4173
// Fetch the latest document after update
4274
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);
@@ -56,7 +88,7 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
5688
<div className="flex items-center justify-between mb-2">
5789
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100">Edit Document</h3>
5890
<div className="flex gap-2">
59-
<Button variant="contained" color="success" size="small" onClick={handleSave} disabled={loading || isSaving} startIcon={isSaving ? <CircularProgress size={18} color="inherit" /> : undefined}>
91+
<Button variant="contained" color="success" size="small" onClick={() => handleSave(false)} disabled={loading || isSaving} startIcon={isSaving ? <CircularProgress size={18} color="inherit" /> : undefined}>
6092
{isSaving ? 'Saving...' : 'Save'}
6193
</Button>
6294
</div>
@@ -96,6 +128,16 @@ const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(
96128
{feedback.message}
97129
</div>
98130
)}
131+
<SaveConflictDialog
132+
open={isConflictDialogOpen}
133+
serverValue={conflictServerDocStr}
134+
localValue={jsonValue}
135+
onClose={() => setIsConflictDialogOpen(false)}
136+
onOverwrite={() => {
137+
setIsConflictDialogOpen(false);
138+
handleSave(true);
139+
}}
140+
/>
99141
</div>
100142
);
101143
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from 'react';
2+
import ReactDiffViewer from 'react-diff-viewer-continued';
3+
import { useTheme } from '../contexts/ThemeContext';
4+
import { WarningIcon } from './icons/material-icons-imports';
5+
6+
interface SaveConflictDialogProps {
7+
open: boolean;
8+
serverValue: string; // The newly fetched data from server
9+
localValue: string; // The user's current edited value
10+
onClose: () => void;
11+
onOverwrite: () => void;
12+
}
13+
14+
const SaveConflictDialog: React.FC<SaveConflictDialogProps> = ({ open, serverValue, localValue, onClose, onOverwrite }) => {
15+
const { theme } = useTheme();
16+
17+
if (!open) return null;
18+
19+
return (
20+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 dark:bg-black/70 backdrop-blur-sm animate-fade-in-fast">
21+
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-2xl max-w-5xl w-[90vw] flex flex-col overflow-hidden border border-slate-200 dark:border-slate-700 max-h-[90vh]">
22+
<header className="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-red-50 dark:bg-red-900/20 flex flex-col gap-1 flex-shrink-0">
23+
<h2 className="text-xl font-bold text-red-600 dark:text-red-500 flex items-center gap-2">
24+
<WarningIcon className="w-6 h-6" /> Save Conflict Detected
25+
</h2>
26+
<p className="text-sm text-slate-700 dark:text-slate-300 ml-8">
27+
The document on the server has been modified since you started editing.
28+
If you save now, you will overwrite the server's changes with your own.
29+
</p>
30+
</header>
31+
32+
<div className="flex-1 overflow-auto p-4 bg-slate-50 dark:bg-slate-900">
33+
<ReactDiffViewer
34+
oldValue={serverValue}
35+
newValue={localValue}
36+
splitView={true}
37+
useDarkTheme={theme === 'dark'}
38+
leftTitle="Server Document"
39+
rightTitle="Your Edits"
40+
extraLinesSurroundingDiff={3}
41+
styles={{
42+
variables: {
43+
light: { diffViewerBackground: '#fff', addedBackground: '#e6ffed', removedBackground: '#ffeef0' },
44+
dark: { diffViewerBackground: '#1e293b', addedBackground: '#044B53', removedBackground: '#632F34', wordAddedBackground: '#055d67', wordRemovedBackground: '#7d3840' }
45+
},
46+
line: {
47+
fontSize: '13px',
48+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
49+
}
50+
}}
51+
/>
52+
</div>
53+
54+
<footer className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3 flex-shrink-0 bg-white dark:bg-slate-800">
55+
<button
56+
onClick={onClose}
57+
className="px-5 py-2.5 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors font-medium"
58+
>
59+
Cancel Save
60+
</button>
61+
<button
62+
onClick={onOverwrite}
63+
className="px-5 py-2.5 rounded-md border border-red-500 bg-red-500 text-white font-semibold hover:bg-red-600 transition-colors flex items-center gap-2"
64+
>
65+
<WarningIcon className="w-5 h-5 text-red-100" /> Force Overwrite Server
66+
</button>
67+
</footer>
68+
</div>
69+
</div>
70+
);
71+
};
72+
73+
export default SaveConflictDialog;

0 commit comments

Comments
 (0)