diff --git a/lib/features/mongodb/mongo_add_document_dialog.dart b/lib/features/mongodb/mongo_add_document_dialog.dart new file mode 100644 index 0000000..4f004a2 --- /dev/null +++ b/lib/features/mongodb/mongo_add_document_dialog.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const kDefaultDraftMongoDocument = '{\n \n}'; + +/// Shows a dialog to create and insert a new MongoDB document. +Future?> showMongoAddDocumentDialog( + material.BuildContext context, { + required String database, + required String collection, + String initialTemplate = kDefaultDraftMongoDocument, +}) async { + return showAppDialog>( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: MongoAddDocumentDialog( + database: database, + collection: collection, + initialTemplate: initialTemplate, + ), + ), + ); +} + +class MongoAddDocumentDialog extends material.StatefulWidget { + const MongoAddDocumentDialog({ + super.key, + required this.database, + required this.collection, + this.initialTemplate = kDefaultDraftMongoDocument, + }); + + final String database; + final String collection; + final String initialTemplate; + + @override + material.State createState() => + _MongoAddDocumentDialogState(); +} + +class _MongoAddDocumentDialogState extends material.State { + late final material.TextEditingController _controller; + String? _error; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.initialTemplate); + _controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + if (_error != null) { + setState(() => _error = null); + } + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final text = _controller.text.trim(); + if (text.isEmpty) { + setState(() => _error = 'Document cannot be empty'); + return; + } + + try { + final doc = mongoDocumentFromEjson(text); + material.Navigator.of(context).pop(doc); + } catch (e) { + setState(() { + _error = 'Invalid EJSON syntax: $e'; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = context.colors; + final editorTheme = context.editorTheme; + + return QueryaDialogCard( + constraints: WindowLayout.dialogConstraints(context, maxWidth: 640), + borderColor: theme.muted, + child: material.SingleChildScrollView( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.Icon(material.Icons.post_add_rounded, + size: 24, color: theme.primary), + const Gap(12), + const Text('Add Document').large().semiBold(), + ], + ), + const Gap(8), + Text('Insert a new document into ${widget.database}.${widget.collection}.') + .muted() + .small(), + ], + ), + ), + const material.Divider(height: 1), + if (_error != null) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 10), + color: theme.destructive.withValues(alpha: 0.1), + child: Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 16, color: theme.destructive), + const Gap(8), + material.Expanded( + child: Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), + ), + ), + ], + ), + ), + const material.Divider(height: 1), + ], + material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Document (Extended JSON)').small().semiBold(), + const Gap(8), + material.Container( + height: 180, + decoration: material.BoxDecoration( + color: editorTheme.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _error != null + ? theme.destructive + : theme.border.withValues(alpha: 0.4), + ), + ), + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.json, + fontSize: 13, + variant: QueryaCodeEditorVariant.material, + contentPadding: const material.EdgeInsets.all(12), + ), + ), + ], + ), + ), + const material.Divider(height: 1), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _submit, + leading: + const material.Icon(material.Icons.add_rounded, size: 16), + child: const Text('Insert Document'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 98700e7..6b84db7 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -4,6 +4,7 @@ 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/features/mongodb/mongo_add_document_dialog.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'; @@ -153,12 +154,19 @@ class _MongoDocumentsViewState extends material.State { } Future _addDocument() async { + final doc = await showMongoAddDocumentDialog( + context, + database: widget.database, + collection: widget.collection, + ); + if (doc == null) return; + try { await MongoService.instance.insertDocument( widget.connection, widget.database, widget.collection, - {}, + doc, ); await _load(); } catch (e) { diff --git a/test/features/mongodb/mongo_add_document_dialog_test.dart b/test/features/mongodb/mongo_add_document_dialog_test.dart new file mode 100644 index 0000000..3b70495 --- /dev/null +++ b/test/features/mongodb/mongo_add_document_dialog_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/features/mongodb/mongo_add_document_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showMongoAddDocumentDialog', () { + testWidgets( + 'dialog shows Add Document title, target collection, and buttons', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showMongoAddDocumentDialog( + context, + database: 'analytics', + collection: 'events', + ), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Add Document'), findsOneWidget); + expect( + find.text('Insert a new document into analytics.events.'), + findsOneWidget, + ); + expect(find.text('Document (Extended JSON)'), findsOneWidget); + expect(find.byType(QueryaCodeEditor), findsOneWidget); + expect(find.text('Insert Document'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + Map? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showMongoAddDocumentDialog( + context, + database: 'analytics', + collection: 'events', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + + testWidgets('Invalid EJSON displays error banner and does not close dialog', + (tester) async { + Map? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showMongoAddDocumentDialog( + context, + database: 'analytics', + collection: 'events', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Enter invalid JSON + await tester.enterText( + find.byType(material.EditableText), '{broken json:'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Insert Document')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Invalid EJSON syntax'), findsOneWidget); + expect(result, isNull); + expect(find.text('Add Document'), findsOneWidget); + }); + + testWidgets('Valid EJSON closes dialog and returns parsed document', + (tester) async { + Map? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showMongoAddDocumentDialog( + context, + database: 'analytics', + collection: 'events', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Enter valid EJSON + await tester.enterText( + find.byType(material.EditableText), + '{"status": "active", "views": 10}', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Insert Document')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!['status'], 'active'); + expect(result!['views'], 10); + expect(find.text('Add Document'), findsNothing); + }); + }); +}