Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 110 additions & 32 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,18 +45,25 @@ Future<void> 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,
});

final SqlExecutionDelegate delegate;
final SqlDialect dialect;

/// Names the exported files: `<databaseName>-erd.svg`.
final String databaseName;

/// Called on double tap of a table card.
final void Function(String table)? onOpenTable;
final ErdFileSaver? onSaveFile;
Expand Down Expand Up @@ -351,7 +361,33 @@ class _ErdViewState extends material.State<ErdView> {
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());
}

/// `<database>-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.
Expand Down Expand Up @@ -462,13 +498,28 @@ class _ErdViewState extends material.State<ErdView> {
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(
Expand Down Expand Up @@ -618,11 +669,13 @@ class _ErdViewState extends material.State<ErdView> {
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'),
Expand Down Expand Up @@ -656,32 +709,57 @@ class _ErdViewState extends material.State<ErdView> {
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),
],
),
),
),
),
),
],
),
Expand Down
1 change: 1 addition & 0 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,7 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
key: material.ValueKey(session.id),
delegate: widget.delegate,
dialect: widget.dialect,
databaseName: effectiveDatabase,
onOpenTable: _openTableFromDiagram,
);
}
Expand Down
26 changes: 22 additions & 4 deletions test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<svg'));
expect(utf8.decode(saved['erd.svg']!), contains('<svg'));

final card = find.byKey(const material.ValueKey('erd_table_users'));
await t.tap(card);
Expand Down Expand Up @@ -646,6 +650,20 @@ void main() {
expect(find.text('amount'), findsOneWidget);
});

testWidgets('a failed load shows a titled state with Retry', (t) async {
await t.pumpWidget(queryaThemeTestShell(
child: ErdView(
delegate: FakeSqlExecutionDelegate(
onExecute: (sql) => 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(
Expand Down
Loading