From b2cf92302985503906db15d1b89d1d69bff8d6a0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 06:06:52 +0300 Subject: [PATCH 1/4] feat(mongodb): multi-mode document viewer (Table Grid / Tree / JSON) (#1026) --- lib/core/storage/app_settings.dart | 18 + .../mongodb/mongo_document_tree_view.dart | 735 ++++++++++++++++++ .../mongodb/mongo_documents_json_view.dart | 119 +++ .../mongodb/mongo_documents_table_view.dart | 262 +++++++ .../mongodb/mongo_documents_view.dart | 169 +++- lib/features/mongodb/mongo_ejson.dart | 16 + .../mongo_documents_view_modes_test.dart | 219 ++++++ 7 files changed, 1518 insertions(+), 20 deletions(-) create mode 100644 lib/features/mongodb/mongo_document_tree_view.dart create mode 100644 lib/features/mongodb/mongo_documents_json_view.dart create mode 100644 lib/features/mongodb/mongo_documents_table_view.dart create mode 100644 test/features/mongodb/mongo_documents_view_modes_test.dart diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 15178249..3871178f 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -131,6 +131,7 @@ abstract final class AppSettingsKeys { static const updateDismissedVersion = 'update_dismissed_version'; static const hasCompletedWelcomeTour = 'has_completed_welcome_tour'; static const confirmDestructiveOperations = 'confirm_destructive_operations'; + static const mongoDocumentsViewMode = 'mongo_documents_view_mode'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -758,4 +759,21 @@ class AppSettings { ); AppSettingsRevision.bump(); } + + /// Active view mode for MongoDB documents browser ('table', 'tree', 'json', 'cards'). + Future getMongoDocumentsViewMode() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.mongoDocumentsViewMode, + ); + if (v == null || v.isEmpty) return 'table'; + return v; + } + + Future setMongoDocumentsViewMode(String mode) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.mongoDocumentsViewMode, + mode, + ); + AppSettingsRevision.bump(); + } } diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart new file mode 100644 index 00000000..9c0e7cbe --- /dev/null +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -0,0 +1,735 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Appends a key or list index to a JavaScript / JSON Path expression. +/// +/// Examples: +/// - parent = '', key = 'address' => 'address' +/// - parent = 'address', key = 'geo' => 'address.geo' +/// - parent = 'address.geo', index = 0 => 'address.geo[0]' +/// - parent = 'address.geo[0]', key = 'lat' => 'address.geo[0].lat' +String appendJsonPath(String parentPath, Object keyOrIndex) { + if (keyOrIndex is int) { + return '$parentPath[$keyOrIndex]'; + } + final keyStr = keyOrIndex.toString(); + if (parentPath.isEmpty) { + return keyStr; + } + if (RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$').hasMatch(keyStr)) { + return '$parentPath.$keyStr'; + } + final escaped = keyStr.replaceAll('"', r'\"'); + return '$parentPath["$escaped"]'; +} + +/// Interactive folding tree view for MongoDB documents. +class MongoDocumentTreeView extends StatefulWidget { + const MongoDocumentTreeView({ + super.key, + required this.documents, + this.startIndex = 0, + this.onDocumentTap, + this.onDeleteDocument, + this.onInspectField, + }); + + final List> documents; + final int startIndex; + final ValueChanged>? onDocumentTap; + final ValueChanged>? onDeleteDocument; + final void Function(Map doc, String field)? onInspectField; + + @override + State createState() => _MongoDocumentTreeViewState(); +} + +class _MongoDocumentTreeViewState extends State { + final Set _expandedNodeIds = {}; + final material.TextEditingController _searchController = + material.TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + // Default to expanding all root document nodes + for (var i = 0; i < widget.documents.length; i++) { + _expandedNodeIds.add('doc_$i'); + } + } + + @override + void didUpdateWidget(covariant MongoDocumentTreeView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.documents, widget.documents)) { + for (var i = 0; i < widget.documents.length; i++) { + _expandedNodeIds.add('doc_$i'); + } + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _toggleNode(String nodeId) { + setState(() { + if (_expandedNodeIds.contains(nodeId)) { + _expandedNodeIds.remove(nodeId); + } else { + _expandedNodeIds.add(nodeId); + } + }); + } + + void _expandAll() { + final allIds = {}; + for (var i = 0; i < widget.documents.length; i++) { + allIds.add('doc_$i'); + _collectContainerIds(widget.documents[i], 'doc_$i', '', allIds); + } + setState(() { + _expandedNodeIds.addAll(allIds); + }); + } + + void _collectContainerIds( + Object? value, + String prefix, + String parentPath, + Set out, + ) { + if (value is Map) { + for (final entry in value.entries) { + final currentPath = appendJsonPath(parentPath, entry.key); + if (entry.value is Map || entry.value is List) { + out.add('${prefix}_$currentPath'); + _collectContainerIds(entry.value, prefix, currentPath, out); + } + } + } else if (value is List) { + for (var i = 0; i < value.length; i++) { + final currentPath = appendJsonPath(parentPath, i); + if (value[i] is Map || value[i] is List) { + out.add('${prefix}_$currentPath'); + _collectContainerIds(value[i], prefix, currentPath, out); + } + } + } + } + + void _collapseAll() { + setState(() { + _expandedNodeIds.clear(); + }); + } + + Future _copyText(String text, String label) async { + await Clipboard.setData(ClipboardData(text: text)); + if (mounted) { + showAppToast( + context: context, + message: '$label copied to clipboard', + variant: AppToastVariant.info, + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (widget.documents.isEmpty) { + return material.Center( + child: const Text('No documents found').muted(), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Tree Toolbar + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.1), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + // Search input + material.SizedBox( + width: 200, + height: 28, + child: TextField( + controller: _searchController, + placeholder: const Text('Filter keys / values...'), + onChanged: (val) { + setState(() { + _searchQuery = val.trim().toLowerCase(); + }); + }, + ), + ), + const Gap(8), + if (_searchQuery.isNotEmpty) + GhostButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + size: ButtonSize.small, + child: const material.Icon( + material.Icons.close_rounded, + size: 14, + ), + ), + const Spacer(), + OutlineButton( + onPressed: _expandAll, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.unfold_more_rounded, + size: 14, + ), + child: const Text('Expand All'), + ), + const Gap(8), + OutlineButton( + onPressed: _collapseAll, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.unfold_less_rounded, + size: 14, + ), + child: const Text('Collapse All'), + ), + ], + ), + ), + // Virtualized Document Tree List + material.Expanded( + child: material.ListView.builder( + padding: const material.EdgeInsets.all(12), + itemCount: widget.documents.length, + itemBuilder: (context, i) { + return _buildDocumentNode(i, widget.documents[i], cs); + }, + ), + ), + ], + ); + } + + Widget _buildDocumentNode( + int docIndex, + Map doc, + ColorScheme cs, + ) { + final docNodeId = 'doc_$docIndex'; + final isExpanded = _expandedNodeIds.contains(docNodeId); + final idVal = doc['_id']; + final idString = idVal is ObjectId ? idVal.oid : idVal?.toString() ?? 'none'; + final fieldsCount = doc.keys.length; + + return material.Container( + margin: const material.EdgeInsets.only(bottom: 8), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.35), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Document Header + material.InkWell( + onTap: () => _toggleNode(docNodeId), + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: Row( + children: [ + material.Icon( + isExpanded + ? material.Icons.expand_more_rounded + : material.Icons.chevron_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + const Gap(6), + material.Icon( + material.Icons.description_outlined, + size: 16, + color: cs.primary, + ), + const Gap(8), + Text( + '#${widget.startIndex + docIndex + 1}', + style: const material.TextStyle( + fontWeight: material.FontWeight.w600, + fontSize: 13, + ), + ), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.25), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '_id: $idString', + style: material.TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: cs.mutedForeground, + ), + ), + ), + const Gap(8), + Text('$fieldsCount fields').muted().small(), + const Spacer(), + // Actions + _TreeIconButton( + icon: material.Icons.copy_rounded, + tooltip: 'Copy Document JSON', + onTap: () => _copyText( + mongoDocumentToEjson(doc), + 'Document JSON', + ), + ), + if (widget.onDocumentTap != null) ...[ + const Gap(4), + _TreeIconButton( + icon: material.Icons.open_in_new_rounded, + tooltip: 'View / Edit Document', + onTap: () => widget.onDocumentTap!(doc), + ), + ], + if (widget.onDeleteDocument != null) ...[ + const Gap(4), + _TreeIconButton( + icon: material.Icons.delete_outline_rounded, + tooltip: 'Delete Document', + color: cs.destructive, + onTap: () => widget.onDeleteDocument!(doc), + ), + ], + ], + ), + ), + ), + // Expanded Tree content + if (isExpanded) ...[ + material.Divider( + height: 1, + color: cs.border.withValues(alpha: 0.2), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + vertical: 6, + horizontal: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: doc.entries.map((entry) { + return _buildSubtree( + docIndex: docIndex, + rootDoc: doc, + parentPath: '', + keyOrIndex: entry.key, + value: entry.value, + depth: 1, + cs: cs, + ); + }).toList(), + ), + ), + ], + ], + ), + ); + } + + Widget _buildSubtree({ + required int docIndex, + required Map rootDoc, + required String parentPath, + required Object keyOrIndex, + required Object? value, + required int depth, + required ColorScheme cs, + }) { + final currentPath = appendJsonPath(parentPath, keyOrIndex); + final nodeId = 'doc_${docIndex}_$currentPath'; + + if (value is Map) { + final isExpanded = _expandedNodeIds.contains(nodeId); + final count = value.length; + final keyLabel = keyOrIndex.toString(); + + // Check search match + final matchesSearch = _searchQuery.isEmpty || + keyLabel.toLowerCase().contains(_searchQuery) || + currentPath.toLowerCase().contains(_searchQuery); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _buildContainerRow( + depth: depth, + keyLabel: keyLabel, + badgeLabel: '{ $count ${count == 1 ? "field" : "fields"} }', + isExpanded: isExpanded, + jsonPath: currentPath, + rawSubtree: value, + cs: cs, + onToggle: () => _toggleNode(nodeId), + matchesSearch: matchesSearch, + ), + if (isExpanded) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: value.entries.map((e) { + return _buildSubtree( + docIndex: docIndex, + rootDoc: rootDoc, + parentPath: currentPath, + keyOrIndex: e.key, + value: e.value, + depth: depth + 1, + cs: cs, + ); + }).toList(), + ), + ), + ], + ); + } + + if (value is List) { + final isExpanded = _expandedNodeIds.contains(nodeId); + final count = value.length; + final keyLabel = keyOrIndex.toString(); + + final matchesSearch = _searchQuery.isEmpty || + keyLabel.toLowerCase().contains(_searchQuery) || + currentPath.toLowerCase().contains(_searchQuery); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _buildContainerRow( + depth: depth, + keyLabel: keyLabel, + badgeLabel: '[ $count ${count == 1 ? "item" : "items"} ]', + isExpanded: isExpanded, + jsonPath: currentPath, + rawSubtree: value, + cs: cs, + onToggle: () => _toggleNode(nodeId), + matchesSearch: matchesSearch, + ), + if (isExpanded) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < value.length; i++) + _buildSubtree( + docIndex: docIndex, + rootDoc: rootDoc, + parentPath: currentPath, + keyOrIndex: i, + value: value[i], + depth: depth + 1, + cs: cs, + ), + ], + ), + ), + ], + ); + } + + // Leaf node + return _buildLeafRow( + depth: depth, + keyOrIndex: keyOrIndex, + value: value, + jsonPath: currentPath, + rootDoc: rootDoc, + cs: cs, + ); + } + + Widget _buildContainerRow({ + required int depth, + required String keyLabel, + required String badgeLabel, + required bool isExpanded, + required String jsonPath, + required Object rawSubtree, + required ColorScheme cs, + required VoidCallback onToggle, + required bool matchesSearch, + }) { + return material.Container( + height: 28, + padding: material.EdgeInsets.only(left: depth * 14.0), + child: Row( + children: [ + material.InkWell( + onTap: onToggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.Icon( + isExpanded + ? material.Icons.expand_more_rounded + : material.Icons.chevron_right_rounded, + size: 16, + color: cs.mutedForeground, + ), + ), + ), + const Gap(4), + Text( + keyLabel, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: matchesSearch && _searchQuery.isNotEmpty + ? cs.primary + : cs.foreground, + ), + ), + const Gap(6), + Text( + badgeLabel, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const Spacer(), + _TreeIconButton( + icon: material.Icons.alt_route_rounded, + tooltip: 'Copy JSON Path ($jsonPath)', + onTap: () => _copyText(jsonPath, 'JSON Path: $jsonPath'), + ), + const Gap(4), + _TreeIconButton( + icon: material.Icons.copy_rounded, + tooltip: 'Copy JSON', + onTap: () { + try { + final encoded = const JsonEncoder.withIndent(' ') + .convert(rawSubtree); + _copyText(encoded, 'JSON'); + } catch (_) { + _copyText(rawSubtree.toString(), 'Value'); + } + }, + ), + ], + ), + ); + } + + Widget _buildLeafRow({ + required int depth, + required Object keyOrIndex, + required Object? value, + required String jsonPath, + required Map rootDoc, + required ColorScheme cs, + }) { + final keyLabel = keyOrIndex is int ? '[$keyOrIndex]' : keyOrIndex.toString(); + final valueDisplay = _formatLeafDisplay(value); + final valueColor = _leafValueColor(value, cs); + final typeName = _leafTypeName(value); + + final isMatch = _searchQuery.isNotEmpty && + (keyLabel.toLowerCase().contains(_searchQuery) || + valueDisplay.toLowerCase().contains(_searchQuery) || + jsonPath.toLowerCase().contains(_searchQuery)); + + final canEdit = depth == 1 && + keyOrIndex is String && + widget.onInspectField != null && + !mongoFieldIsReadOnly(keyOrIndex); + + return material.Container( + height: 28, + padding: material.EdgeInsets.only(left: depth * 14.0 + 16.0), + child: Row( + children: [ + Text( + keyLabel, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: isMatch ? cs.primary : cs.foreground, + ), + ), + Text( + ': ', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: cs.mutedForeground, + ), + ), + material.Expanded( + child: Text( + valueDisplay, + overflow: TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontStyle: + value == null ? material.FontStyle.italic : material.FontStyle.normal, + color: valueColor, + ), + ), + ), + const Gap(6), + // Type badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(3), + ), + child: Text( + typeName, + style: material.TextStyle( + fontSize: 10, + color: cs.mutedForeground, + ), + ), + ), + const Gap(6), + _TreeIconButton( + icon: material.Icons.alt_route_rounded, + tooltip: 'Copy JSON Path ($jsonPath)', + onTap: () => _copyText(jsonPath, 'JSON Path: $jsonPath'), + ), + const Gap(4), + _TreeIconButton( + icon: material.Icons.copy_rounded, + tooltip: 'Copy Value', + onTap: () => _copyText(valueDisplay, 'Value'), + ), + if (canEdit) ...[ + const Gap(4), + _TreeIconButton( + icon: material.Icons.edit_note_rounded, + tooltip: 'Edit Field', + onTap: () => widget.onInspectField!(rootDoc, keyOrIndex), + ), + ], + ], + ), + ); + } + + String _formatLeafDisplay(Object? value) { + if (value == null) return 'null'; + if (value is String) return '"$value"'; + if (value is ObjectId) return 'ObjectId("${value.oid}")'; + if (value is DateTime) return 'ISODate("${value.toUtc().toIso8601String()}")'; + return value.toString(); + } + + Color _leafValueColor(Object? value, ColorScheme cs) { + if (value == null) return cs.mutedForeground; + if (value is String) return const Color(0xFF22C55E); // Green + if (value is num) return const Color(0xFF3B82F6); // Blue + if (value is bool) return const Color(0xFFF97316); // Orange + if (value is ObjectId) return cs.primary; // Primary / Purple + if (value is DateTime) return const Color(0xFF06B6D4); // Cyan + return cs.foreground; + } + + String _leafTypeName(Object? value) { + if (value == null) return 'null'; + if (value is String) return 'str'; + if (value is int) return 'int'; + if (value is double) return 'dbl'; + if (value is bool) return 'bool'; + if (value is ObjectId) return 'oid'; + if (value is DateTime) return 'date'; + return value.runtimeType.toString(); + } +} + +class _TreeIconButton extends StatelessWidget { + const _TreeIconButton({ + required this.icon, + required this.tooltip, + required this.onTap, + this.color, + }); + + final material.IconData icon; + final String tooltip; + final VoidCallback onTap; + final Color? color; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.Tooltip( + message: tooltip, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(3), + child: material.Icon( + icon, + size: 14, + color: color ?? cs.mutedForeground, + ), + ), + ), + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_json_view.dart b/lib/features/mongodb/mongo_documents_json_view.dart new file mode 100644 index 00000000..9794835f --- /dev/null +++ b/lib/features/mongodb/mongo_documents_json_view.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Full-page formatted Extended JSON viewer using [QueryaCodeEditor]. +class MongoDocumentsJsonView extends StatefulWidget { + const MongoDocumentsJsonView({ + super.key, + required this.documents, + }); + + final List> documents; + + @override + State createState() => _MongoDocumentsJsonViewState(); +} + +class _MongoDocumentsJsonViewState extends State { + late material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController( + text: mongoDocumentsToEjson(widget.documents), + ); + } + + @override + void didUpdateWidget(covariant MongoDocumentsJsonView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.documents, widget.documents)) { + _controller.text = mongoDocumentsToEjson(widget.documents); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _copyJson() async { + await Clipboard.setData(ClipboardData(text: _controller.text)); + if (mounted) { + showAppToast( + context: context, + message: 'JSON copied to clipboard', + variant: AppToastVariant.info, + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (widget.documents.isEmpty) { + return material.Center( + child: const Text('No documents found').muted(), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Action toolbar + material.Container( + height: 36, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.1), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + material.Icon( + material.Icons.code_rounded, + size: 15, + color: cs.mutedForeground, + ), + const Gap(8), + Text('${widget.documents.length} documents (Extended JSON)') + .muted() + .small(), + const Spacer(), + OutlineButton( + onPressed: _copyJson, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 13, + ), + child: const Text('Copy JSON'), + ), + ], + ), + ), + // Code Editor + material.Expanded( + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.json, + readOnly: true, + expands: true, + ), + ), + ], + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_table_view.dart b/lib/features/mongodb/mongo_documents_table_view.dart new file mode 100644 index 00000000..4de8f57e --- /dev/null +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -0,0 +1,262 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Precomputed column and cell model for [MongoDocumentsTableView]. +class MongoTableGridData { + const MongoTableGridData({ + required this.columns, + required this.rows, + required this.columnDataTypes, + }); + + final List columns; + final List> rows; + final Map columnDataTypes; + + factory MongoTableGridData.fromDocuments(List> documents) { + if (documents.isEmpty) { + return const MongoTableGridData( + columns: [], + rows: [], + columnDataTypes: {}, + ); + } + + final columnSet = {}; + for (final doc in documents) { + columnSet.addAll(doc.keys); + } + + final columns = []; + if (columnSet.remove('_id')) { + columns.add('_id'); + } + final otherCols = columnSet.toList()..sort(); + columns.addAll(otherCols); + + final columnDataTypes = {}; + for (final col in columns) { + for (final doc in documents) { + final val = doc[col]; + if (val != null) { + columnDataTypes[col] = _inferType(val); + break; + } + } + } + + final rows = >[]; + for (final doc in documents) { + final row = []; + for (final col in columns) { + if (!doc.containsKey(col)) { + row.add(''); + } else { + final val = doc[col]; + row.add(_formatCell(val)); + } + } + rows.add(row); + } + + return MongoTableGridData( + columns: columns, + rows: rows, + columnDataTypes: columnDataTypes, + ); + } + + static String _inferType(Object val) { + if (val is ObjectId) return 'ObjectId'; + if (val is Map) return 'Object'; + if (val is List) return 'Array'; + if (val is int) return 'Int32'; + if (val is double) return 'Double'; + if (val is bool) return 'Boolean'; + if (val is DateTime) return 'Date'; + if (val is String) return 'String'; + return val.runtimeType.toString(); + } + + static String _formatCell(Object? val) { + if (val == null) return 'NULL'; + if (val is ObjectId) return val.oid; + if (val is DateTime) return val.toUtc().toIso8601String(); + if (val is Map) { + final n = val.length; + return '{ $n ${n == 1 ? "field" : "fields"} }'; + } + if (val is List) { + final n = val.length; + return '[ $n ${n == 1 ? "item" : "items"} ]'; + } + if (val is String) return val; + return val.toString(); + } +} + +/// Table Grid view for MongoDB documents using [VirtualResultGrid]. +class MongoDocumentsTableView extends StatefulWidget { + const MongoDocumentsTableView({ + super.key, + required this.documents, + this.onDocumentTap, + this.onDeleteDocument, + this.onInspectField, + }); + + final List> documents; + final ValueChanged>? onDocumentTap; + final ValueChanged>? onDeleteDocument; + final void Function(Map doc, String field)? onInspectField; + + @override + State createState() => + _MongoDocumentsTableViewState(); +} + +class _MongoDocumentsTableViewState extends State { + late MongoTableGridData _gridData; + int? _selectedDocIndex; + + @override + void initState() { + super.initState(); + _gridData = MongoTableGridData.fromDocuments(widget.documents); + } + + @override + void didUpdateWidget(covariant MongoDocumentsTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.documents, widget.documents)) { + _gridData = MongoTableGridData.fromDocuments(widget.documents); + if (_selectedDocIndex != null && + _selectedDocIndex! >= widget.documents.length) { + _selectedDocIndex = null; + } + } + } + + Future _copySelectedDocJson(Map doc) async { + final ejson = mongoDocumentToEjson(doc); + await Clipboard.setData(ClipboardData(text: ejson)); + if (mounted) { + showAppToast( + context: context, + message: 'Document JSON copied to clipboard', + variant: AppToastVariant.info, + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (widget.documents.isEmpty || _gridData.columns.isEmpty) { + return material.Center( + child: const Text('No documents found').muted(), + ); + } + + final selectedDoc = (_selectedDocIndex != null && + _selectedDocIndex! >= 0 && + _selectedDocIndex! < widget.documents.length) + ? widget.documents[_selectedDocIndex!] + : null; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Action / Status bar for selected row + material.Container( + height: 36, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.1), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + if (selectedDoc != null) ...[ + material.Icon( + material.Icons.check_circle_outline_rounded, + size: 14, + color: cs.primary, + ), + const Gap(6), + Text('Row ${_selectedDocIndex! + 1} selected').small(), + if (selectedDoc['_id'] != null) ...[ + const Gap(8), + Text('(${selectedDoc['_id']})').muted().small(), + ], + const Spacer(), + if (widget.onDocumentTap != null) ...[ + OutlineButton( + onPressed: () => widget.onDocumentTap!(selectedDoc), + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.open_in_new_rounded, + size: 13, + ), + child: const Text('View Document'), + ), + const Gap(6), + ], + OutlineButton( + onPressed: () => _copySelectedDocJson(selectedDoc), + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 13, + ), + child: const Text('Copy JSON'), + ), + if (widget.onDeleteDocument != null) ...[ + const Gap(6), + DestructiveButton( + onPressed: () => widget.onDeleteDocument!(selectedDoc), + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.delete_outline_rounded, + size: 13, + ), + child: const Text('Delete'), + ), + ], + ] else ...[ + Text('${widget.documents.length} rows · Click a row to select · Click column header to sort') + .muted() + .small(), + ], + ], + ), + ), + // Grid + material.Expanded( + child: VirtualResultGrid( + columns: _gridData.columns, + rows: _gridData.rows, + columnDataTypes: _gridData.columnDataTypes, + onRowSelected: (rowIndex) { + setState(() { + _selectedDocIndex = rowIndex; + }); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 8f0c0c31..69ea0c53 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -4,7 +4,11 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/mongodb/mongo_add_document_dialog.dart'; +import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; @@ -14,6 +18,25 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultLimit = 25; +/// View modes for MongoDB documents viewer. +enum MongoDocumentsViewMode { + table('table', 'Table'), + tree('tree', 'Tree'), + json('json', 'JSON'), + cards('cards', 'Cards'); + + const MongoDocumentsViewMode(this.id, this.label); + final String id; + final String label; + + static MongoDocumentsViewMode fromString(String? value) { + for (final mode in MongoDocumentsViewMode.values) { + if (mode.id == value) return mode; + } + return MongoDocumentsViewMode.table; + } +} + /// Paginated document browser for a MongoDB collection. class MongoDocumentsView extends material.StatefulWidget { const MongoDocumentsView({ @@ -23,6 +46,7 @@ class MongoDocumentsView extends material.StatefulWidget { required this.collection, this.onDocumentTap, this.refreshToken = 0, + this.initialViewMode, }); final MongoConnection connection; @@ -33,6 +57,9 @@ class MongoDocumentsView extends material.StatefulWidget { /// Incremented by the parent when the user requests a refresh (toolbar). final int refreshToken; + /// Optional initial view mode (overrides persisted setting, e.g. for testing). + final MongoDocumentsViewMode? initialViewMode; + @override material.State createState() => _MongoDocumentsViewState(); @@ -80,13 +107,39 @@ class _MongoDocumentsViewState extends material.State { final _filterController = material.TextEditingController(); Map? _activeFilter; String? _emptyFilterHint; + late MongoDocumentsViewMode _viewMode; @override void initState() { super.initState(); + _viewMode = widget.initialViewMode ?? MongoDocumentsViewMode.table; + if (widget.initialViewMode == null) { + unawaited(_loadInitialViewMode()); + } _load(); } + Future _loadInitialViewMode() async { + try { + final saved = await AppSettings.instance.getMongoDocumentsViewMode(); + if (mounted) { + setState(() { + _viewMode = MongoDocumentsViewMode.fromString(saved); + }); + } + } catch (_) { + // Keep current mode + } + } + + void _setViewMode(MongoDocumentsViewMode mode) { + if (_viewMode == mode) return; + setState(() { + _viewMode = mode; + }); + unawaited(AppSettings.instance.setMongoDocumentsViewMode(mode.id)); + } + @override void didUpdateWidget(covariant MongoDocumentsView oldWidget) { super.didUpdateWidget(oldWidget); @@ -334,7 +387,7 @@ class _MongoDocumentsViewState extends material.State { const Divider(height: 1), // Error banner if (_error != null) _buildErrorBanner(cs), - // Document list (virtualized) + // Document content (mode-dependent) material.Expanded( child: _documents.isEmpty ? material.Center( @@ -344,25 +397,48 @@ class _MongoDocumentsViewState extends material.State { .muted(), ), ) - : material.ListView.separated( - padding: const material.EdgeInsets.all(16), - cacheExtent: 400, - itemCount: _documents.length, - separatorBuilder: (_, __) => const Gap(8), - itemBuilder: (context, i) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; - return _DocumentCard( - document: _documents[i], - index: _skip + i, - colorScheme: cs, - shadcnCs: shadcnCs, - onView: () => widget.onDocumentTap?.call(_documents[i]), - onDelete: () => _deleteDocument(_documents[i]), - onInspectField: (field) => - unawaited(_inspectField(_documents[i], field)), - ); - }, - ), + : switch (_viewMode) { + MongoDocumentsViewMode.table => MongoDocumentsTableView( + documents: _documents, + onDocumentTap: widget.onDocumentTap, + onDeleteDocument: (doc) => + unawaited(_deleteDocument(doc)), + onInspectField: (doc, field) => + unawaited(_inspectField(doc, field)), + ), + MongoDocumentsViewMode.tree => MongoDocumentTreeView( + documents: _documents, + startIndex: _skip, + onDocumentTap: widget.onDocumentTap, + onDeleteDocument: (doc) => + unawaited(_deleteDocument(doc)), + onInspectField: (doc, field) => + unawaited(_inspectField(doc, field)), + ), + MongoDocumentsViewMode.json => MongoDocumentsJsonView( + documents: _documents, + ), + MongoDocumentsViewMode.cards => material.ListView.separated( + padding: const material.EdgeInsets.all(16), + cacheExtent: 400, + itemCount: _documents.length, + separatorBuilder: (_, __) => const Gap(8), + itemBuilder: (context, i) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return _DocumentCard( + document: _documents[i], + index: _skip + i, + colorScheme: cs, + shadcnCs: shadcnCs, + onView: () => + widget.onDocumentTap?.call(_documents[i]), + onDelete: () => _deleteDocument(_documents[i]), + onInspectField: (field) => + unawaited(_inspectField(_documents[i], field)), + ); + }, + ), + }, ), // Pagination bar _buildPaginationBar(cs), @@ -405,6 +481,59 @@ class _MongoDocumentsViewState extends material.State { child: const Text('Clear'), ), const Gap(12), + // View Mode Switcher + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: MongoDocumentsViewMode.table, + label: material.Text('Table'), + icon: material.Icon( + material.Icons.table_chart_rounded, + size: 14, + ), + ), + material.ButtonSegment( + value: MongoDocumentsViewMode.tree, + label: material.Text('Tree'), + icon: material.Icon( + material.Icons.account_tree_rounded, + size: 14, + ), + ), + material.ButtonSegment( + value: MongoDocumentsViewMode.json, + label: material.Text('JSON'), + icon: material.Icon( + material.Icons.code_rounded, + size: 14, + ), + ), + material.ButtonSegment( + value: MongoDocumentsViewMode.cards, + label: material.Text('Cards'), + icon: material.Icon( + material.Icons.view_stream_rounded, + size: 14, + ), + ), + ], + selected: {_viewMode}, + onSelectionChanged: (selected) { + _setViewMode(selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + const Gap(12), PrimaryButton( onPressed: _addDocument, size: ButtonSize.small, diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart index d8afb8be..ab6239e0 100644 --- a/lib/features/mongodb/mongo_ejson.dart +++ b/lib/features/mongodb/mongo_ejson.dart @@ -14,6 +14,22 @@ String mongoDocumentToEjson(Map doc) { return const JsonEncoder.withIndent(' ').convert(ejson); } +/// Pretty-prints a list of MongoDB documents as a relaxed Extended JSON array. +String mongoDocumentsToEjson(List> docs) { + if (docs.isEmpty) return '[]'; + final list = docs.map((doc) { + try { + return EJsonCodec.deserialize( + BsonCodec.serialize(Map.from(doc)), + relaxed: true, + ); + } catch (_) { + return doc; + } + }).toList(); + return const JsonEncoder.withIndent(' ').convert(list); +} + /// Parses Extended JSON (canonical or relaxed) back into Dart BSON values. Map mongoDocumentFromEjson(String text) { final decoded = json.decode(text); diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart new file mode 100644 index 00000000..d674180e --- /dev/null +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('MongoDocumentsViewMode', () { + test('fromString parses correctly and defaults to table', () { + expect(MongoDocumentsViewMode.fromString('table'), MongoDocumentsViewMode.table); + expect(MongoDocumentsViewMode.fromString('tree'), MongoDocumentsViewMode.tree); + expect(MongoDocumentsViewMode.fromString('json'), MongoDocumentsViewMode.json); + expect(MongoDocumentsViewMode.fromString('cards'), MongoDocumentsViewMode.cards); + expect(MongoDocumentsViewMode.fromString('unknown'), MongoDocumentsViewMode.table); + expect(MongoDocumentsViewMode.fromString(null), MongoDocumentsViewMode.table); + }); + }); + + group('appendJsonPath', () { + test('constructs JSON paths according to specification', () { + expect(appendJsonPath('', 'address'), 'address'); + expect(appendJsonPath('address', 'geo'), 'address.geo'); + expect(appendJsonPath('address.geo', 0), 'address.geo[0]'); + expect(appendJsonPath('address.geo[0]', 'lat'), 'address.geo[0].lat'); + expect(appendJsonPath('user', 'first-name'), 'user["first-name"]'); + expect(appendJsonPath('data', 'space key'), 'data["space key"]'); + }); + }); + + group('mongoDocumentsToEjson', () { + test('formats empty list as empty JSON array', () { + expect(mongoDocumentsToEjson([]), '[]'); + }); + + test('formats list of documents into relaxed EJSON array', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final docs = [ + { + '_id': id, + 'name': 'Test', + 'nested': {'k': 'v'}, + 'items': [1, 2], + } + ]; + final json = mongoDocumentsToEjson(docs); + expect(json, contains(r'$oid')); + expect(json, contains('507f1f77bcf86cd799439011')); + expect(json, contains('"name": "Test"')); + expect(json, contains('"items": [')); + }); + }); + + group('MongoTableGridData', () { + test('extracts columns with _id first and formats nested badges', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final docs = [ + { + 'title': 'Hello', + '_id': id, + 'author': { + 'first': 'Jane', + 'last': 'Doe', + }, + 'tags': ['a', 'b', 'c'], + 'views': 42, + 'active': true, + 'notes': null, + }, + { + '_id': ObjectId.fromHexString('507f1f77bcf86cd799439012'), + 'title': 'World', + 'extra': 'value', + } + ]; + + final grid = MongoTableGridData.fromDocuments(docs); + + // _id must be first + expect(grid.columns.first, '_id'); + expect(grid.columns, containsAll(['_id', 'author', 'tags', 'title', 'views', 'active', 'notes', 'extra'])); + + // Inferred types + expect(grid.columnDataTypes['_id'], 'ObjectId'); + expect(grid.columnDataTypes['author'], 'Object'); + expect(grid.columnDataTypes['tags'], 'Array'); + expect(grid.columnDataTypes['views'], 'Int32'); + expect(grid.columnDataTypes['active'], 'Boolean'); + expect(grid.columnDataTypes['title'], 'String'); + + // Formatted cells for first doc + final row0 = grid.rows[0]; + final idIndex = grid.columns.indexOf('_id'); + final authorIndex = grid.columns.indexOf('author'); + final tagsIndex = grid.columns.indexOf('tags'); + final notesIndex = grid.columns.indexOf('notes'); + + expect(row0[idIndex], '507f1f77bcf86cd799439011'); + expect(row0[authorIndex], '{ 2 fields }'); + expect(row0[tagsIndex], '[ 3 items ]'); + expect(row0[notesIndex], 'NULL'); + + // Formatted cells for second doc (missing fields are empty string) + final row1 = grid.rows[1]; + expect(row1[authorIndex], ''); + }); + }); + + group('MongoDocumentsTableView widget', () { + testWidgets('renders table grid view and status bar', (tester) async { + final docs = [ + { + '_id': ObjectId.fromHexString('507f1f77bcf86cd799439011'), + 'name': 'Querya', + }, + { + '_id': ObjectId.fromHexString('507f1f77bcf86cd799439012'), + 'name': 'MongoDB', + }, + ]; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: ShadcnApp( + home: MongoDocumentsTableView( + documents: docs, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(MongoDocumentsTableView), findsOneWidget); + expect(find.text('2 rows · Click a row to select · Click column header to sort'), findsOneWidget); + }); + }); + + group('MongoDocumentTreeView widget', () { + testWidgets('renders document tree with folding and search', (tester) async { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final docs = [ + { + '_id': id, + 'name': 'Doc1', + 'address': { + 'geo': [ + {'lat': 10.0} + ] + } + }, + ]; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: ShadcnApp( + home: MongoDocumentTreeView( + documents: docs, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(MongoDocumentTreeView), findsOneWidget); + expect(find.text('#1'), findsOneWidget); + expect(find.text('name'), findsOneWidget); + expect(find.text('"Doc1"'), findsOneWidget); + expect(find.text('address'), findsOneWidget); + expect(find.text('Expand All'), findsOneWidget); + expect(find.text('Collapse All'), findsOneWidget); + + // Tap Collapse All + await tester.tap(find.text('Collapse All')); + await tester.pumpAndSettle(); + + // Tap Expand All + await tester.tap(find.text('Expand All')); + await tester.pumpAndSettle(); + expect(find.text('address'), findsOneWidget); + }); + }); + + group('MongoDocumentsJsonView widget', () { + testWidgets('renders code editor and copy button', (tester) async { + final docs = [ + { + 'field': 'value', + } + ]; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: ShadcnApp( + home: MongoDocumentsJsonView( + documents: docs, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(MongoDocumentsJsonView), findsOneWidget); + expect(find.text('Copy JSON'), findsOneWidget); + expect(find.text('1 documents (Extended JSON)'), findsOneWidget); + }); + }); +} From 6073201b539583bd705238ccc8126f01189a6efc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 06:22:54 +0300 Subject: [PATCH 2/4] fix(mongodb): disambiguate State import between mongo_dart and flutter --- lib/features/mongodb/mongo_document_tree_view.dart | 2 +- lib/features/mongodb/mongo_documents_table_view.dart | 2 +- test/features/mongodb/mongo_documents_view_modes_test.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index 9c0e7cbe..052826cf 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; -import 'package:mongo_dart/mongo_dart.dart'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; diff --git a/lib/features/mongodb/mongo_documents_table_view.dart b/lib/features/mongodb/mongo_documents_table_view.dart index 4de8f57e..22d6cbaa 100644 --- a/lib/features/mongodb/mongo_documents_table_view.dart +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -2,7 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; -import 'package:mongo_dart/mongo_dart.dart'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index d674180e..5dcb665b 100644 --- a/test/features/mongodb/mongo_documents_view_modes_test.dart +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:mongo_dart/mongo_dart.dart'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; From 512e117d9e8db1080262b85528db50fe247067d7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 06:31:21 +0300 Subject: [PATCH 3/4] fix(mongodb): remove unused import and wrap widget tests in theme shell --- .../mongodb/mongo_documents_table_view.dart | 2 -- .../mongo_documents_view_modes_test.dart | 32 +++++++------------ 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/lib/features/mongodb/mongo_documents_table_view.dart b/lib/features/mongodb/mongo_documents_table_view.dart index 22d6cbaa..665650c9 100644 --- a/lib/features/mongodb/mongo_documents_table_view.dart +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -1,5 +1,3 @@ -import 'dart:async' show unawaited; - import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:mongo_dart/mongo_dart.dart' show ObjectId; diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index 5dcb665b..7ca74f95 100644 --- a/test/features/mongodb/mongo_documents_view_modes_test.dart +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -8,6 +8,8 @@ import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/querya_theme_test_shell.dart'; + void main() { group('MongoDocumentsViewMode', () { test('fromString parses correctly and defaults to table', () { @@ -123,13 +125,9 @@ void main() { ]; await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( - body: ShadcnApp( - home: MongoDocumentsTableView( - documents: docs, - ), - ), + queryaThemeTestShell( + child: MongoDocumentsTableView( + documents: docs, ), ), ); @@ -157,13 +155,9 @@ void main() { ]; await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( - body: ShadcnApp( - home: MongoDocumentTreeView( - documents: docs, - ), - ), + queryaThemeTestShell( + child: MongoDocumentTreeView( + documents: docs, ), ), ); @@ -198,13 +192,9 @@ void main() { ]; await tester.pumpWidget( - material.MaterialApp( - home: material.Scaffold( - body: ShadcnApp( - home: MongoDocumentsJsonView( - documents: docs, - ), - ), + queryaThemeTestShell( + child: MongoDocumentsJsonView( + documents: docs, ), ), ); From b3fb6ca49a69b64ffd36ba438ec1daecea918139 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 06:46:32 +0300 Subject: [PATCH 4/4] test(mongodb): remove unused imports in view modes test --- test/features/mongodb/mongo_documents_view_modes_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index 7ca74f95..d8e5b5df 100644 --- a/test/features/mongodb/mongo_documents_view_modes_test.dart +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; @@ -6,7 +5,6 @@ import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart';