diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 8b9f4383..c0fdb316 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -20,7 +20,10 @@ import 'package:querya_desktop/features/erd/erd_layout.dart'; import 'package:querya_desktop/features/erd/erd_model.dart'; import 'package:querya_desktop/features/erd/erd_router.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; +import 'package:querya_desktop/shared/widgets/querya_empty_state.dart'; import 'package:querya_desktop/shared/widgets/querya_search_field.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; import 'package:querya_desktop/shared/widgets/querya_action_button.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; @@ -42,11 +45,15 @@ Future defaultErdFileSaver(String name, Uint8List bytes) async { } /// Interactive entity-relationship diagram of the connected database. +/// Entries of the diagram's Export menu. +enum _ExportAction { mermaid, svg, png, copyMermaid } + class ErdView extends material.StatefulWidget { const ErdView({ super.key, required this.delegate, required this.dialect, + this.databaseName = '', this.onOpenTable, this.onSaveFile, }); @@ -54,6 +61,9 @@ class ErdView extends material.StatefulWidget { final SqlExecutionDelegate delegate; final SqlDialect dialect; + /// Names the exported files: `-erd.svg`. + final String databaseName; + /// Called on double tap of a table card. final void Function(String table)? onOpenTable; final ErdFileSaver? onSaveFile; @@ -351,7 +361,33 @@ class _ErdViewState extends material.State { final image = await boundary.toImage(pixelRatio: ratio); final data = await image.toByteData(format: ui.ImageByteFormat.png); if (data == null) return; - await _save('diagram.png', data.buffer.asUint8List()); + await _save('$_fileStem.png', data.buffer.asUint8List()); + } + + /// `-erd`, or `erd` when the database name is unknown. + String get _fileStem => + widget.databaseName.isEmpty ? 'erd' : '${widget.databaseName}-erd'; + + void _export(_ExportAction action, ErdSchema schema, ErdLayout layout) { + switch (action) { + case _ExportAction.mermaid: + _save( + '$_fileStem.mmd', + Uint8List.fromList(utf8.encode(ErdExport.toMermaid(schema)))); + case _ExportAction.svg: + _save( + '$_fileStem.svg', + Uint8List.fromList(utf8.encode( + ErdExport.toSvg(schema, layout, routes: _routes)))); + case _ExportAction.png: + _exportPng(); + case _ExportAction.copyMermaid: + Clipboard.setData(ClipboardData(text: ErdExport.toMermaid(schema))); + showAppToast( + context: context, + message: 'Mermaid copied to the clipboard', + ); + } } /// Pointer over the canvas: names the relation within 6 px of it, if any. @@ -462,13 +498,28 @@ class _ErdViewState extends material.State { final layout = _layout; material.Widget body; if (_loading) { - body = const material.Center(child: QueryaSpinner()); + body = const material.Center( + child: QueryaSpinner(label: 'Loading schema…')); } else if (_error != null) { - body = material.Center(child: Text(_error!)); + body = material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.error_outline_rounded, + color: wb.destructive), + title: 'Could not load the schema', + description: _error, + actionLabel: 'Retry', + onAction: _load, + ), + ); } else if (schema == null || layout == null || schema.isEmpty) { body = material.Center( - child: Text('No tables found', - style: material.TextStyle(color: wb.mutedForeground)), + child: QueryaEmptyState( + icon: material.Icon(material.Icons.table_chart_outlined, + color: wb.mutedForeground), + title: 'No tables found', + description: + 'This schema has no tables, or this role cannot see them.', + ), ); } else { body = material.CallbackShortcuts( @@ -618,11 +669,13 @@ class _ErdViewState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Padding( + material.Container( + decoration: SqlEditorChrome.sqlToolbarDecoration(context), padding: const material.EdgeInsets.all(8), child: material.Wrap( spacing: 8, runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, children: [ QueryaActionButton( key: const material.ValueKey('erd_refresh'), @@ -656,32 +709,57 @@ class _ErdViewState extends material.State { tooltip: 'Arrange the tables again (undoes manual moves)', onPressed: ready ? _autoLayout : null, ), - QueryaActionButton( - key: const material.ValueKey('erd_mermaid'), - label: 'Mermaid', - onPressed: !ready - ? null - : () => _save( - 'diagram.mmd', - Uint8List.fromList( - utf8.encode(ErdExport.toMermaid(schema))), - ), - ), - QueryaActionButton( - key: const material.ValueKey('erd_svg'), - label: 'SVG', - onPressed: !ready - ? null - : () => _save( - 'diagram.svg', - Uint8List.fromList(utf8.encode( - ErdExport.toSvg(schema, layout, routes: _routes))), - ), - ), - QueryaActionButton( - key: const material.ValueKey('erd_png'), - label: 'PNG', - onPressed: ready ? _exportPng : null, + material.IgnorePointer( + ignoring: !ready, + child: material.Opacity( + opacity: ready ? 1 : 0.5, + child: QueryaActionMenu<_ExportAction>( + items: const [ + QueryaActionMenuItem( + value: _ExportAction.mermaid, + label: 'Mermaid (.mmd)', + icon: material.Icons.account_tree_outlined, + ), + QueryaActionMenuItem( + value: _ExportAction.svg, + label: 'SVG', + icon: material.Icons.polyline_outlined, + ), + QueryaActionMenuItem( + value: _ExportAction.png, + label: 'PNG', + icon: material.Icons.image_outlined, + ), + QueryaActionMenuItem( + value: _ExportAction.copyMermaid, + label: 'Copy Mermaid', + icon: material.Icons.content_copy_rounded, + ), + ], + onSelected: (action) { + if (schema != null && layout != null) { + _export(action, schema, layout); + } + }, + child: material.Padding( + key: const material.ValueKey('erd_export'), + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.file_download_outlined, + size: 16, color: wb.mutedForeground), + const material.SizedBox(width: 6), + const Text('Export'), + const material.SizedBox(width: 4), + material.Icon(material.Icons.expand_more_rounded, + size: 16, color: wb.mutedForeground), + ], + ), + ), + ), + ), ), ], ), diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 2ecd6519..1c0daba2 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -917,6 +917,7 @@ class GenericSqlWorkspaceState extends material.State { key: material.ValueKey(session.id), delegate: widget.delegate, dialect: widget.dialect, + databaseName: effectiveDatabase, onOpenTable: _openTableFromDiagram, ); } diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index c6bad0e4..084cfe7e 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -443,13 +443,17 @@ void main() { expect(find.byKey(const material.ValueKey('erd_table_orders')), findsOneWidget); - await t.tap(find.byKey(const material.ValueKey('erd_mermaid'))); + await t.tap(find.byKey(const material.ValueKey('erd_export'))); await t.pump(); - expect(utf8.decode(saved['diagram.mmd']!), contains('erDiagram')); + await t.tap(find.text('Mermaid (.mmd)')); + await t.pump(); + expect(utf8.decode(saved['erd.mmd']!), contains('erDiagram')); - await t.tap(find.byKey(const material.ValueKey('erd_svg'))); + await t.tap(find.byKey(const material.ValueKey('erd_export'))); + await t.pump(); + await t.tap(find.text('SVG')); await t.pump(); - expect(utf8.decode(saved['diagram.svg']!), contains(' throw StateError('no access')), + dialect: SqlDialect.sqlite, + ), + )); + await t.pump(); + await t.pump(); + expect(find.text('Could not load the schema'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + testWidgets('shows empty state', (t) async { await t.pumpWidget(queryaThemeTestShell( child: ErdView(