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
5 changes: 4 additions & 1 deletion lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ class ErdExport {
}
b.writeln(' }');
}
// The FK end as on screen: zero or many when the column may be NULL,
// one or many otherwise.
for (final r in schema.relations) {
final many = r.optional ? 'o{' : '|{';
b.writeln(
' ${_id(r.toTable)} ||--o{ ${_id(r.fromTable)} : "${r.fromColumn.replaceAll('"', '')}"');
' ${_id(r.toTable)} ||--$many ${_id(r.fromTable)} : "${r.fromColumn.replaceAll('"', '')}"');
}
return b.toString();
}
Expand Down
58 changes: 36 additions & 22 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -561,10 +561,12 @@ class _ErdViewState extends material.State<ErdView> {
}
}

/// Pointer over the canvas: names the relation within 6 px of it, if any.
/// Pointer over the canvas ([p] in canvas space): names the relation within
/// 6 screen px of it, if any, whatever the zoom.
void _hoverCanvas(material.Offset p) {
ErdRelation? hit;
var best = 6.0;
final scale = _scale;
var best = 6.0 / (scale <= 0 ? 1 : scale);
for (final r in _routes) {
final d = ErdGeometry.distanceToRoute(r.points, p);
if (d <= best) {
Expand Down Expand Up @@ -826,26 +828,6 @@ class _ErdViewState extends material.State<ErdView> {
),
),
),
material.Positioned.fill(
child: material.IgnorePointer(
child: material.ValueListenableBuilder<
(ErdRelation, material.Offset)?>(
valueListenable: _edgeTipNotifier,
builder: (context, tip, _) => tip == null
? const material.SizedBox.shrink()
: material.Stack(
children: [
material.Positioned(
left: tip.$2.dx + 12,
top: tip.$2.dy + 12,
child: _edgeTipLabel(
context, tip.$1),
),
],
),
),
),
),
],
);
},
Expand All @@ -854,6 +836,32 @@ class _ErdViewState extends material.State<ErdView> {
),
),
),
// The edge label sits above the zoomed canvas, in screen space,
// so it stays readable at any zoom and is never in a PNG.
material.Positioned.fill(
child: material.IgnorePointer(
child: material.ListenableBuilder(
listenable: material.Listenable.merge(
[_edgeTipNotifier, _transform]),
builder: (context, _) {
final tip = _edgeTipNotifier.value;
if (tip == null) return const material.SizedBox.shrink();
final at = material.MatrixUtils.transformPoint(
_transform.value, tip.$2);
return material.Stack(
children: [
material.Positioned(
key: const material.ValueKey('erd_edge_tip'),
left: at.dx + 12,
top: at.dy + 12,
child: _edgeTipLabel(context, tip.$1),
),
],
);
},
),
),
),
material.Positioned(
left: 8,
top: 8,
Expand Down Expand Up @@ -1203,6 +1211,12 @@ class _RelationPainter extends material.CustomPainter {
}
}

/// Edges take no pointer: a CustomPaint is hit everywhere by default, which
/// hid the pointer from the hover layer below and so the edge label never
/// showed.
@override
bool? hitTest(material.Offset position) => false;

@override
bool shouldRepaint(_RelationPainter old) =>
old.routes != routes ||
Expand Down
79 changes: 77 additions & 2 deletions test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';

import 'package:flutter/gestures.dart' show kSecondaryButton;
import 'package:flutter/gestures.dart'
show PointerDeviceKind, kSecondaryButton;
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -387,7 +388,18 @@ void main() {
expect(m, contains('users {'));
expect(m, contains('integer id PK'));
expect(m, contains('integer user_id FK'));
expect(m, contains('users ||--o{ orders : "user_id"'));
// user_id is NOT NULL here: one or many.
expect(m, contains('users ||--|{ orders : "user_id"'));
});

test('mermaid marks a nullable foreign key as zero or many', () {
final s = ErdSchema.fromCatalog(columnRows: [
['users', 'id', 'int', '1', '0'],
['orders', 'user_id', 'int', '0', '1'],
], fkRows: [
['orders', 'user_id', 'users', 'id'],
]);
expect(ErdExport.toMermaid(s), contains('users ||--o{ orders'));
});

test('mermaid sanitizes odd identifiers', () {
Expand Down Expand Up @@ -674,6 +686,69 @@ void main() {
expect(find.text('Show relations'), findsNothing);
});

testWidgets('hovering an edge names its columns at any zoom', (t) async {
await t.binding.setSurfaceSize(const material.Size(1200, 800));
await t.pumpWidget(queryaThemeTestShell(
child: ErdView(
source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite)),
));
await t.pump();
await t.pump();

// The same schema, layout and routes as the view draws.
final schema = ErdSchema.fromCatalog(columnRows: [
['users', 'id', 'INTEGER', '1'],
['orders', 'user_id', 'INTEGER', '0'],
], fkRows: [
['orders', 'user_id', 'users', 'id'],
]);
final layout = ErdLayout.compute(schema);
final route = ErdRouter.route(schema, layout).single.points;
// Middle of the longest segment: on the edge, away from the cards.
var a = route[0], b = route[1];
for (var i = 1; i < route.length; i++) {
if ((route[i] - route[i - 1]).distance > (b - a).distance) {
a = route[i - 1];
b = route[i];
}
}
final onEdge = (a + b) / 2;

material.Offset toScreen(material.Offset canvas, double scale) {
final card = t.getTopLeft(
find.byKey(const material.ValueKey('erd_table_users')));
return card + (canvas - layout.positions['users']!) * scale;
}

double scale() => int.parse(t
.widget<material.Text>(
find.byKey(const material.ValueKey('erd_zoom_label')))
.data!
.replaceAll('%', '')) /
100;

final mouse = await t.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: material.Offset.zero);
await mouse.moveTo(toScreen(onEdge, scale()));
await t.pump();
const label = 'orders.user_id → users.id';
expect(find.text(label), findsOneWidget);
final height = t.getSize(find.byKey(const material.ValueKey('erd_edge_tip'))).height;

// Zoomed out, the label keeps its screen size.
await mouse.moveTo(material.Offset.zero);
await t.pump();
await t.tap(find.byKey(const material.ValueKey('erd_zoom_out')));
await t.pump();
await mouse.moveTo(toScreen(onEdge, scale()));
await t.pump();
expect(find.text(label), findsOneWidget);
expect(
t.getSize(find.byKey(const material.ValueKey('erd_edge_tip'))).height,
closeTo(height, 0.5));
});

testWidgets('a card can be dragged and Auto layout puts it back',
(t) async {
await t.binding.setSurfaceSize(const material.Size(1200, 800));
Expand Down
Loading