Skip to content

Commit a7dd5dd

Browse files
committed
feat: Implement multiple document filtering with various operators in the Data Explorer.
1 parent 8acb561 commit a7dd5dd

File tree

5 files changed

+243
-73
lines changed

5 files changed

+243
-73
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
135+
else:
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"}}
120150
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}
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

frontend/pages/DataExplorerPage.tsx

Lines changed: 130 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ const DeleteDocumentDialog: React.FC<{
148148
const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
149149
resource,
150150
dbInfo,
151-
accountName,
152151
availableDbs,
153152
availableAccounts,
154153
initialDocumentId,
@@ -182,13 +181,31 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
182181
const [pageInput, setPageInput] = useState(String(currentPage));
183182

184183
// --- Filtering State ---
185-
const [filterKey, setFilterKey] = useState('all');
186-
const [filterValue, setFilterValue] = useState('');
187-
const [debouncedFilterValue, setDebouncedFilterValue] = useState(filterValue);
184+
interface FilterState {
185+
id: string;
186+
key: string;
187+
value: string;
188+
isCustom: boolean;
189+
operator?: string;
190+
}
191+
const [filters, setFilters] = useState<FilterState[]>([{ id: 'default', key: 'all', value: '', isCustom: false, operator: 'equals' }]);
192+
const [debouncedFilters, setDebouncedFilters] = useState<FilterState[]>(filters);
188193
const [schemaTree, setSchemaTree] = useState<SchemaKeyNode[]>([]);
189194
const [isFetchingSchema, setIsFetchingSchema] = useState(false);
190195
const [currentCollectionInfo, setCurrentCollectionInfo] = useState<CollectionInfo | null>(null);
191196

197+
const addFilter = () => {
198+
setFilters(prev => [...prev, { id: Math.random().toString(36).substring(7), key: 'all', value: '', isCustom: false, operator: 'equals' }]);
199+
};
200+
201+
const removeFilter = (id: string) => {
202+
setFilters(prev => prev.filter(f => f.id !== id));
203+
};
204+
205+
const updateFilter = (id: string, updates: Partial<FilterState>) => {
206+
setFilters(prev => prev.map(f => f.id === id ? { ...f, ...updates } : f));
207+
};
208+
192209
// --- Editor State ---
193210
const [selectedDocument, setSelectedDocument] = useState<Record<string, any> | null>(null);
194211
const [editMode, setEditMode] = useState(false);
@@ -354,25 +371,24 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
354371
setTotalPages(1);
355372
setTotalDocuments(0);
356373
setPageInput('1');
357-
setFilterKey('all');
358-
setFilterValue('');
359-
setDebouncedFilterValue('');
374+
setFilters([{ id: 'default', key: 'all', value: '', isCustom: false, operator: 'equals' }]);
375+
setDebouncedFilters([{ id: 'default', key: 'all', value: '', isCustom: false, operator: 'equals' }]);
360376
setSchemaTree([]);
361377
setIsFetchingSchema(false);
362378
setSelectedDocument(null);
363379
setBreadcrumbs([]);
364380
// Do not reset pinned documents here, as they should persist across DB/collection changes.
365381
}, []);
366382

367-
// Debounce search input
383+
// Debounce filters
368384
useEffect(() => {
369385
const handler = setTimeout(() => {
370-
setDebouncedFilterValue(filterValue);
386+
setDebouncedFilters(filters);
371387
setCurrentPage(1); // Reset to page 1 on new search
372388
setSelectedDocument(null); // Clear selection on new search
373389
}, 300);
374390
return () => clearTimeout(handler);
375-
}, [filterValue]);
391+
}, [filters]);
376392

377393
// Sync page input with current page
378394
useEffect(() => setPageInput(String(currentPage)), [currentPage]);
@@ -432,8 +448,12 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
432448
setIsLoading(true);
433449
setError(null);
434450
try {
435-
const processedValue = getCoercedFilterValue(debouncedFilterValue);
436-
const response = await getDocuments(selectedCollection, currentResource, currentPage, 20, { key: filterKey, value: processedValue });
451+
const activeFilters = debouncedFilters.map(f => ({
452+
key: f.key,
453+
value: getCoercedFilterValue(f.value),
454+
operator: f.operator || 'equals'
455+
}));
456+
const response = await getDocuments(selectedCollection, currentResource, currentPage, 20, undefined, activeFilters);
437457
setDocuments(response.documents);
438458
setTotalPages(response.totalPages);
439459
setTotalDocuments(response.totalDocuments);
@@ -446,7 +466,7 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
446466
} finally {
447467
setIsLoading(false);
448468
}
449-
}, [selectedCollection, currentResource, currentPage, filterKey, debouncedFilterValue]);
469+
}, [selectedCollection, currentResource, currentPage, debouncedFilters]);
450470

451471
useEffect(() => {
452472
if (breadcrumbs.length > 0 && selectedDocument) return;
@@ -569,8 +589,8 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
569589
if (selectedCollection === collectionName) return;
570590
setSelectedCollection(collectionName);
571591
setCurrentPage(1);
572-
setFilterValue('');
573-
setFilterKey('all');
592+
setFilters([{ id: 'default', key: 'all', value: '', isCustom: false, operator: 'equals' }]);
593+
setDebouncedFilters([{ id: 'default', key: 'all', value: '', isCustom: false, operator: 'equals' }]);
574594
setSelectedDocument(null);
575595
setBreadcrumbs([]);
576596
await fetchSchemaForCollection(collectionName);
@@ -715,7 +735,7 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
715735
return (
716736
<div className="text-center text-slate-500 dark:text-slate-400 py-10">
717737
<p>No documents found.</p>
718-
{debouncedFilterValue && <p className="text-xs">Try a different filter or value.</p>}
738+
{debouncedFilters.some(f => f.value || f.operator === 'exists' || f.operator === 'not_exists') && <p className="text-xs">Try a different filter or value.</p>}
719739
</div>
720740
);
721741
}
@@ -1237,30 +1257,100 @@ const DataExplorerPage: React.FC<DataExplorerPageProps> = ({
12371257
</div>
12381258
</div>
12391259
<div className="space-y-2">
1240-
<div className="relative">
1241-
<select
1242-
value={filterKey}
1243-
onChange={(e) => setFilterKey(e.target.value)}
1244-
disabled={!selectedCollection || isFetchingSchema}
1245-
className="w-full text-sm appearance-none cursor-pointer p-2 pr-8 bg-slate-100 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1246-
title="Select a field to filter by"
1247-
>
1248-
<option value="all">All Fields</option>
1249-
<RenderOptions nodes={schemaTree} level={0} />
1250-
</select>
1251-
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 pointer-events-none" />
1252-
</div>
1253-
<div className="relative">
1254-
<input
1255-
type="text"
1256-
placeholder={isFetchingSchema ? 'Loading schema...' : 'Filter value...'}
1257-
value={filterValue}
1258-
onChange={(e) => setFilterValue(e.target.value)}
1259-
disabled={!selectedCollection || isFetchingSchema}
1260-
className="w-full pl-9 pr-4 py-2 bg-slate-100 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1261-
/>
1262-
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500" />
1263-
</div>
1260+
{filters.map((f, i) => (
1261+
<div key={f.id} className="flex flex-col gap-2 p-3 bg-slate-50 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-lg relative">
1262+
{filters.length > 1 && (
1263+
<button onClick={() => removeFilter(f.id)} className="absolute -top-2 -right-2 p-1 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-full text-slate-400 hover:text-red-500 hover:border-red-500 shadow-sm z-10 transition-colors">
1264+
<XIcon className="w-3 h-3" />
1265+
</button>
1266+
)}
1267+
<div className="flex gap-2">
1268+
<div className="flex-[2_2_0%] relative">
1269+
{!f.isCustom ? (
1270+
<>
1271+
<select
1272+
value={f.key}
1273+
onChange={(e) => {
1274+
if (e.target.value === '__custom__') {
1275+
updateFilter(f.id, { isCustom: true, key: '' });
1276+
} else {
1277+
updateFilter(f.id, { key: e.target.value });
1278+
}
1279+
}}
1280+
disabled={!selectedCollection || isFetchingSchema}
1281+
className="w-full text-sm appearance-none cursor-pointer p-2 pr-8 bg-white dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1282+
title="Select a field to filter by"
1283+
>
1284+
<option value="all">All Fields</option>
1285+
<RenderOptions nodes={schemaTree} level={0} />
1286+
<option disabled>──────────</option>
1287+
<option value="__custom__">Type custom field...</option>
1288+
</select>
1289+
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 pointer-events-none" />
1290+
</>
1291+
) : (
1292+
<div className="flex items-center relative">
1293+
<input
1294+
type="text"
1295+
value={f.key}
1296+
onChange={(e) => updateFilter(f.id, { key: e.target.value })}
1297+
disabled={!selectedCollection || isFetchingSchema}
1298+
placeholder="e.g. internal_info.internal_id"
1299+
className="w-full text-sm p-2 pr-8 bg-white dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1300+
autoFocus
1301+
/>
1302+
<button
1303+
onClick={() => updateFilter(f.id, { isCustom: false, key: 'all' })}
1304+
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors bg-slate-200 dark:bg-slate-600"
1305+
title="Back to dropdown"
1306+
>
1307+
<XIcon className="w-3 h-3" />
1308+
</button>
1309+
</div>
1310+
)}
1311+
</div>
1312+
{f.key !== 'all' && (
1313+
<div className="flex-1 relative">
1314+
<select
1315+
value={f.operator || 'equals'}
1316+
onChange={(e) => updateFilter(f.id, { operator: e.target.value })}
1317+
disabled={!selectedCollection || isFetchingSchema}
1318+
className="w-full text-sm appearance-none cursor-pointer p-2 pr-8 bg-white dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1319+
>
1320+
<option value="equals">Equals</option>
1321+
<option value="not_equals">Does Not Equal</option>
1322+
<option value="contains">Contains</option>
1323+
<option value="greater_than">Greater Than (&gt;)</option>
1324+
<option value="less_than">Less Than (&lt;)</option>
1325+
<option value="exists">Exists</option>
1326+
<option value="not_exists">Does Not Exist</option>
1327+
</select>
1328+
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 pointer-events-none" />
1329+
</div>
1330+
)}
1331+
</div>
1332+
{(!f.operator || (f.operator !== 'exists' && f.operator !== 'not_exists')) && (
1333+
<div className="relative">
1334+
<input
1335+
type="text"
1336+
placeholder={isFetchingSchema ? 'Loading schema...' : 'Filter value...'}
1337+
value={f.value}
1338+
onChange={(e) => updateFilter(f.id, { value: e.target.value })}
1339+
disabled={!selectedCollection || isFetchingSchema}
1340+
className="w-full pl-9 pr-4 py-2 bg-white dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50"
1341+
/>
1342+
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500" />
1343+
</div>
1344+
)}
1345+
</div>
1346+
))}
1347+
<button
1348+
onClick={addFilter}
1349+
disabled={!selectedCollection || isFetchingSchema}
1350+
className="w-full flex justify-center items-center gap-1 p-2 text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600 border border-slate-300 dark:border-slate-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1351+
>
1352+
<span>+ Add Filter</span>
1353+
</button>
12641354
</div>
12651355
</div>
12661356
<div className="flex-grow overflow-hidden">

0 commit comments

Comments
 (0)