Skip to content

Commit 40a760a

Browse files
authored
Merge pull request #24 from ChingEnLin:feat/add_loader
Enhance Data Explorer with loading indicators, authentication, and filtering
2 parents 0cd1f0a + f4b29a9 commit 40a760a

15 files changed

+2571
-1742
lines changed

backend/models/data_documents.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
class DataDocumentsFilter(BaseModel):
66
key: str
7-
value: Any # Can be str, int, etc. depending on the field type
7+
value: Any = "" # Can be str, int, etc. depending on the field type
8+
operator: Optional[str] = "equals" # 'equals', 'exists', 'not_exists'
89

910

1011
class DataDocumentsRequest(BaseModel):
@@ -14,6 +15,7 @@ class DataDocumentsRequest(BaseModel):
1415
page: int = 1
1516
limit: int = 20
1617
filter: Optional[DataDocumentsFilter] = None
18+
filters: Optional[List[DataDocumentsFilter]] = None
1719

1820

1921
class DataDocumentsResponse(BaseModel):

backend/routes/data_documents.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def get_documents(
4646
page=body.page,
4747
limit=body.limit,
4848
filter=body.filter.model_dump() if body.filter else None,
49+
filters=[f.model_dump() for f in body.filters] if body.filters else None,
4950
)
5051

5152

backend/services/data_documents_service.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,40 +89,85 @@ def fetch_documents(
8989
page: int,
9090
limit: int,
9191
filter: dict = None,
92+
filters: list = None,
9293
) -> DataDocumentsResponse:
9394
client = MongoClient(connection_string)
9495
db = client[database_name]
9596
collection = db[collection_name]
9697

9798
query = {}
98-
if filter and ("key" in filter) and ("value" in filter):
99-
key = filter["key"]
100-
value = filter["value"]
99+
and_clauses = []
100+
101+
def get_query_for_filter(f: dict):
102+
key = f.get("key")
103+
value = f.get("value")
104+
operator = f.get("operator", "equals")
105+
106+
if not key:
107+
return {}
108+
109+
if operator == "exists":
110+
return {key: {"$exists": True}}
111+
if operator == "not_exists":
112+
return {key: {"$exists": False}}
113+
114+
if value is None:
115+
return {}
116+
101117
if key == "all":
102118
sample_doc = collection.find_one()
103119
if sample_doc:
104120
or_clauses = []
105121
for k, v in sample_doc.items():
106122
if isinstance(v, str):
107123
or_clauses.append(
108-
{k: {"$regex": re.escape(value), "$options": "i"}}
124+
{k: {"$regex": re.escape(str(value)), "$options": "i"}}
109125
)
110126
if or_clauses:
111-
query = {"$or": or_clauses}
127+
return {"$or": or_clauses}
128+
return {}
112129
else:
113-
# If key is '_id', treat value as ObjectId
114130
if key == "_id":
115131
try:
116-
query = {"_id": ObjectId(value)}
132+
query_val = ObjectId(value)
117133
except Exception:
118-
# fallback to string match if not a valid ObjectId
119-
query = {key: value}
134+
query_val = value
120135
else:
121-
# Support dot notation for nested fields
122-
if isinstance(value, str):
123-
query = {key: {"$regex": re.escape(value), "$options": "i"}}
124-
else:
125-
query = {key: value}
136+
query_val = value
137+
138+
if operator == "not_equals":
139+
return {key: {"$ne": query_val}}
140+
if operator == "greater_than":
141+
return {key: {"$gt": query_val}}
142+
if operator == "less_than":
143+
return {key: {"$lt": query_val}}
144+
if operator == "contains":
145+
return {key: {"$regex": re.escape(str(value)), "$options": "i"}}
146+
147+
# default equals
148+
if isinstance(query_val, str) and key != "_id":
149+
return {key: {"$regex": re.escape(query_val), "$options": "i"}}
150+
else:
151+
return {key: query_val}
152+
153+
if filter and ("key" in filter) and ("value" in filter):
154+
q = get_query_for_filter(filter)
155+
if q:
156+
and_clauses.append(q)
157+
158+
if filters:
159+
for f in filters:
160+
if ("key" in f) and ("value" in f):
161+
q = get_query_for_filter(f)
162+
if q:
163+
and_clauses.append(q)
164+
165+
if and_clauses:
166+
if len(and_clauses) == 1:
167+
query = and_clauses[0]
168+
else:
169+
query = {"$and": and_clauses}
170+
126171
total_documents = collection.count_documents(query)
127172
total_pages = max(1, (total_documents + limit - 1) // limit)
128173
skip = (page - 1) * limit
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 DiffOverwriteDialogProps {
7+
open: boolean;
8+
oldValue: string; // The user's current edited value
9+
newValue: string; // The newly fetched data from server
10+
onClose: () => void;
11+
onOverwrite: () => void;
12+
}
13+
14+
const DiffOverwriteDialog: React.FC<DiffOverwriteDialogProps> = ({ open, oldValue, newValue, 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-amber-50 dark:bg-amber-900/20 flex flex-col gap-1 flex-shrink-0">
23+
<h2 className="text-xl font-bold text-amber-600 dark:text-amber-500 flex items-center gap-2">
24+
<WarningIcon className="w-6 h-6" /> Refresh Will Overwrite Your Edits
25+
</h2>
26+
<p className="text-sm text-slate-700 dark:text-slate-300 ml-8">
27+
The document on the server is different from what you are currently editing.
28+
If you refresh, your current edits will be overwritten with the version from the server.
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={oldValue}
35+
newValue={newValue}
36+
splitView={true}
37+
useDarkTheme={theme === 'dark'}
38+
leftTitle="Your Edits"
39+
rightTitle="Server Document"
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 Refresh
60+
</button>
61+
<button
62+
onClick={onOverwrite}
63+
className="px-5 py-2.5 rounded-md border border-amber-500 bg-amber-500 text-white font-semibold hover:bg-amber-600 transition-colors flex items-center gap-2"
64+
>
65+
<WarningIcon className="w-5 h-5 text-amber-100" /> Overwrite My Edits
66+
</button>
67+
</footer>
68+
</div>
69+
</div>
70+
);
71+
};
72+
73+
export default DiffOverwriteDialog;

frontend/components/DocumentDetailView.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from 'react';
1+
import { useState, forwardRef, useImperativeHandle } from 'react';
22
import { useTheme } from '../contexts/ThemeContext';
33
import { Button, CircularProgress } from '@mui/material';
44
import MonacoEditor from '@monaco-editor/react';
@@ -16,25 +16,31 @@ interface DocumentEditViewProps {
1616
onSave?: () => void | Promise<void>;
1717
}
1818

19-
const DocumentEditView: React.FC<DocumentEditViewProps> = ({ accountId, databaseName, document, collection, docId, loading, onCancel, onSave }) => {
19+
export interface DocumentEditViewRef {
20+
getCurrentValue: () => string;
21+
setCurrentValue: (val: string) => void;
22+
}
23+
24+
const DocumentEditView = forwardRef<DocumentEditViewRef, DocumentEditViewProps>(({ accountId, databaseName, document, collection, docId, loading, onCancel, onSave }, ref) => {
2025
const [jsonValue, setJsonValue] = useState(JSON.stringify(document, null, 2));
2126
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
2227
const { theme } = useTheme();
23-
const [currentDoc, setCurrentDoc] = useState(document);
2428
const [isSaving, setIsSaving] = useState(false);
2529

30+
useImperativeHandle(ref, () => ({
31+
getCurrentValue: () => jsonValue,
32+
setCurrentValue: (val: string) => setJsonValue(val)
33+
}));
34+
2635
const handleSave = async () => {
2736
setIsSaving(true);
2837
try {
2938
const parsed = JSON.parse(jsonValue);
30-
if (!collection || !docId) throw new Error('Missing collection or document ID');
39+
if (!accountId || !databaseName || !collection || !docId) throw new Error('Missing DB info');
3140
await updateDocument(accountId, databaseName, collection, docId, parsed);
3241
// Fetch the latest document after update
33-
if (accountId && databaseName && collection && docId) {
34-
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);
35-
setCurrentDoc(refreshed);
36-
setJsonValue(JSON.stringify(refreshed, null, 2));
37-
}
42+
const refreshed = await getSingleDocument(accountId, databaseName, collection, docId);
43+
setJsonValue(JSON.stringify(refreshed, null, 2));
3844
setFeedback({ type: 'success', message: 'Document saved and refreshed.' });
3945
if (onSave) await onSave();
4046
if (onCancel) onCancel();
@@ -92,6 +98,6 @@ const DocumentEditView: React.FC<DocumentEditViewProps> = ({ accountId, database
9298
)}
9399
</div>
94100
);
95-
};
101+
});
96102

97103
export default DocumentEditView;

frontend/components/DocumentHistoryDialog.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
4343
const fetchDocumentHistory = async () => {
4444
setLoading(true);
4545
setError(null);
46-
46+
4747
try {
4848
const response = await getDocumentHistory(resource, collectionName, documentId);
4949
setHistoryData(response);
@@ -130,11 +130,15 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
130130

131131
const renderDiffData = (entry: DocumentHistoryEntry) => {
132132
if (entry.operation === 'insert') {
133+
const insertData = { ...(entry.diff_data as Record<string, any>) };
134+
delete insertData['datetime_creation'];
135+
delete insertData['datetime_last_modified'];
136+
133137
return (
134138
<div className="mt-2 p-3 bg-green-100 dark:bg-green-900/30 rounded border">
135139
<p className="text-sm font-medium text-green-800 dark:text-green-200 mb-2">Document created with initial data:</p>
136140
<div className="text-xs font-mono bg-white dark:bg-gray-800 p-2 rounded border max-h-48 overflow-y-auto">
137-
<pre className="whitespace-pre-wrap break-words overflow-wrap-anywhere">{JSON.stringify(entry.diff_data, null, 2)}</pre>
141+
<pre className="whitespace-pre-wrap break-words overflow-wrap-anywhere">{JSON.stringify(insertData, null, 2)}</pre>
138142
</div>
139143
</div>
140144
);
@@ -149,8 +153,12 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
149153
}
150154

151155
// Update operation
152-
const changes = Object.entries(entry.diff_data as Record<string, any>);
153-
156+
const diffData = { ...(entry.diff_data as Record<string, any>) };
157+
delete diffData['datetime_creation'];
158+
delete diffData['datetime_last_modified'];
159+
160+
const changes = Object.entries(diffData);
161+
154162
return (
155163
<div className="mt-2 space-y-2">
156164
{changes.map(([field, change], index) => (
@@ -238,7 +246,10 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
238246
{historyData.history_entries.map((entry: DocumentHistoryEntry) => {
239247
const timestamp = formatTimestamp(entry.timestamp_utc);
240248
const isExpanded = expandedEntries.has(entry.id);
241-
const hasChanges = entry.operation === 'update' && Object.keys(entry.diff_data).length > 0;
249+
const diffDataKeys = Object.keys(entry.diff_data || {}).filter(k =>
250+
!['datetime_creation', 'datetime_last_modified'].includes(k)
251+
);
252+
const hasChanges = entry.operation === 'update' && diffDataKeys.length > 0;
242253

243254
return (
244255
<div
@@ -250,7 +261,7 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
250261
<div className="flex-shrink-0 mt-0.5">
251262
{getOperationIcon(entry.operation)}
252263
</div>
253-
264+
254265
<div className="flex-1 min-w-0">
255266
<div className="flex items-center gap-2 mb-1">
256267
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
@@ -262,7 +273,7 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
262273
<span>{entry.user_email}</span>
263274
</div>
264275
</div>
265-
276+
266277
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-2">
267278
<AccessTimeIcon className="w-3 h-3" />
268279
<span>{timestamp.relative}</span>
@@ -276,7 +287,7 @@ const DocumentHistoryDialog: React.FC<DocumentHistoryDialogProps> = ({
276287
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 transition-colors"
277288
>
278289
{isExpanded ? <ChevronDownIcon className="w-3 h-3" /> : <ChevronRightIcon className="w-3 h-3" />}
279-
{Object.keys(entry.diff_data).length} field{Object.keys(entry.diff_data).length !== 1 ? 's' : ''} changed
290+
{diffDataKeys.length} field{diffDataKeys.length !== 1 ? 's' : ''} changed
280291
</button>
281292
)}
282293

frontend/components/icons/material-icons-imports.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,4 @@ export { default as WholeWordIcon } from '@mui/icons-material/ShortText';
6666
export { default as RegexIcon } from '@mui/icons-material/Code';
6767
export { default as DeleteIcon } from '@mui/icons-material/Delete';
6868
export { default as ImageIcon } from '@mui/icons-material/Image';
69+
export { default as WarningIcon } from '@mui/icons-material/WarningAmber';

0 commit comments

Comments
 (0)