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
164 changes: 158 additions & 6 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import 'dart:async' show Timer;

import 'package:flutter/material.dart' as material
show
Center,
DecoratedBox,
DecorationPosition,
DragTarget,
Draggable,
Opacity,
pointerDragAnchorStrategy,
TextEditingValue,
ValueListenableBuilder,
BuildContext,
Expand Down Expand Up @@ -567,6 +574,19 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
List<String> _folders = [];
List<ConnectionRow> _connections = [];
Map<String, int> _folderIdByName = {};

/// The connection being dragged, while a drag is in progress.
int? _draggedId;

@visibleForTesting
int? get draggedConnectionIdForTesting => _draggedId;

/// `id: folderId` of the connections the panel currently holds.
@visibleForTesting
Map<int, int?> get connectionFoldersForTesting => {
for (final c in _connections)
if (c.id != null) c.id!: c.folderId,
};
final Set<String> _expandedFolders = {};
final Set<int> _expandedConnections = {};

Expand Down Expand Up @@ -678,7 +698,9 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
final name = await showNewFolderDialog(menuContext);
if (name == null || !mounted) return;
await FoldersStorage.instance.add(name);
if (mounted) setState(() => _folders = FoldersStorage.instance.folders);
// Reload, not only the names: the new folder needs its id in
// [_folderIdByName] before its contents are looked up.
if (mounted) await _loadData();
}

Future<void> _createConnection({
Expand Down Expand Up @@ -839,7 +861,80 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
}
}

/// A connection tile that can be dragged onto a folder (or onto the list
/// itself to leave its folder).
Widget _buildConnectionTile(ConnectionRow conn) {
final tile = _connectionTileCore(conn);
final id = conn.id;
if (id == null) return tile;
final theme = Theme.of(context);
return material.Draggable<int>(
data: id,
maxSimultaneousDrags: 1,
onDragStarted: () {
if (mounted) setState(() => _draggedId = id);
},
onDragEnd: (_) {
if (mounted) setState(() => _draggedId = null);
},
dragAnchorStrategy: material.pointerDragAnchorStrategy,
feedback: material.Material(
color: material.Colors.transparent,
child: material.Container(
padding:
const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: material.BoxDecoration(
color: theme.colorScheme.popover,
borderRadius: material.BorderRadius.circular(8),
border: material.Border.all(color: theme.colorScheme.primary),
),
child: material.Row(
mainAxisSize: material.MainAxisSize.min,
children: [
material.Icon(QueryaIcons.connectionIcon(conn.type),
size: 16, color: theme.colorScheme.primary),
const material.SizedBox(width: 8),
material.Text(conn.name,
style: material.TextStyle(
fontSize: 13, color: theme.colorScheme.foreground)),
],
),
),
),
childWhenDragging: material.Opacity(opacity: 0.4, child: tile),
child: tile,
);
}

ConnectionRow? _connectionById(int id) {
for (final c in _connections) {
if (c.id == id) return c;
}
return null;
}

/// Whether dropping connection [id] on [folderName] (null: the list itself,
/// meaning "no folder") would change anything.
bool _acceptsDrop(int id, String? folderName) {
final conn = _connectionById(id);
if (conn == null) return false;
final target = folderName == null ? null : _folderIdByName[folderName];
if (folderName != null && target == null) return false;
return conn.folderId != target;
}

Future<void> _dropConnection(int id, String? folderName) async {
final conn = _connectionById(id);
if (conn == null || !_acceptsDrop(id, folderName)) return;
final target = folderName == null ? null : _folderIdByName[folderName];
await _moveConnection(conn, target);
if (folderName != null && mounted) {
// Show what was dropped.
setState(() => _expandedFolders.add(folderName));
}
}

Widget _connectionTileCore(ConnectionRow conn) {
final isSelected = widget.selectedConnectionId != null &&
widget.selectedConnectionId == conn.id;
final isExpanded = _expandedConnections.contains(conn.id);
Expand Down Expand Up @@ -1142,9 +1237,48 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
height: 1,
color: theme.colorScheme.border.withValues(alpha: 0.22),
),
// While a connection from a folder is dragged, a labelled place
// to drop it: the empty part of the list works too, but nobody
// would know.
// A fixed slot: inserting and removing a child here would shift
// the list below it, and rebuilding it drops the tile that is
// being dragged (and the drag with it).
_draggedId != null && _acceptsDrop(_draggedId!, null)
? material.DragTarget<int>(
onWillAcceptWithDetails: (d) => _acceptsDrop(d.data, null),
onAcceptWithDetails: (d) => _dropConnection(d.data, null),
builder: (context, candidates, _) => material.Container(
key: const material.ValueKey('drop_out_of_folder'),
margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 0),
padding: const material.EdgeInsets.symmetric(vertical: 10),
decoration: material.BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: candidates.isEmpty ? 0.06 : 0.18),
borderRadius: material.BorderRadius.circular(8),
border: material.Border.all(
color: theme.colorScheme.primary
.withValues(alpha: candidates.isEmpty ? 0.4 : 1),
width: 1.5,
),
),
child: material.Center(
child: material.Text(
'Drop here to take it out of the folder',
style: material.TextStyle(
fontSize: 12,
color: theme.colorScheme.primary,
),
),
),
),
)
: const material.SizedBox.shrink(),
Expanded(
child: material.RepaintBoundary(
child: material.CustomScrollView(
child: material.DragTarget<int>(
onWillAcceptWithDetails: (d) => _acceptsDrop(d.data, null),
onAcceptWithDetails: (d) => _dropConnection(d.data, null),
builder: (context, candidates, _) => material.CustomScrollView(
slivers: [
material.SliverPadding(
padding: const material.EdgeInsets.symmetric(
Expand Down Expand Up @@ -1177,11 +1311,13 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
_expandedFolders.remove(folderName);
}
},
connections: filteredConnections
.where((c) =>
c.folderId == _folderIdByName[name])
.toList(),
connections: connectionsInFolder(
filteredConnections,
_folderIdByName[name],
),
onRemove: () => _removeFolder(name),
acceptsDrop: (id) => _acceptsDrop(id, name),
onDrop: (id) => _dropConnection(id, name),
onNewConnection: (folderName) async {
final folderId = await LocalDb.instance
.getFolderIdByName(folderName);
Expand Down Expand Up @@ -1249,6 +1385,7 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
),
],
),
),
),
),
],
Expand All @@ -1258,3 +1395,18 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
);
}
}

/// The connections that live in the folder with id [folderId]. A folder whose
/// id is not known yet holds none: matching `folderId == null` there listed
/// every top-level connection inside a freshly created folder.
@visibleForTesting
List<ConnectionRow> connectionsInFolder(
List<ConnectionRow> connections,
int? folderId,
) {
if (folderId == null) return const [];
return [
for (final c in connections)
if (c.folderId == folderId) c,
];
}
32 changes: 32 additions & 0 deletions lib/features/connections/connections_panel_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,20 @@ class _FolderTile extends StatefulWidget {
required this.onNewConnection,
required this.iconForType,
required this.onRemoveConnection,
this.acceptsDrop,
this.onDrop,
this.onConnectionTap,
this.onRedisDatabaseTap,
this.onMongoDBDatabaseTap,
this.buildConnectionTile,
});

/// Whether dropping connection [id] on this folder changes anything.
final bool Function(int id)? acceptsDrop;

/// A connection was dropped on this folder.
final void Function(int id)? onDrop;

final String name;
final bool initiallyExpanded;
final void Function(String folderName, bool expanded) onExpansionCommitted;
Expand Down Expand Up @@ -239,6 +247,30 @@ class _FolderTileState extends State<_FolderTile> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final drop = widget.onDrop;
final menu = _buildMenu(context, theme);
if (drop == null) return menu;
return material.DragTarget<int>(
onWillAcceptWithDetails: (d) => widget.acceptsDrop?.call(d.data) ?? true,
onAcceptWithDetails: (d) => drop(d.data),
builder: (context, candidates, _) => material.DecoratedBox(
key: material.ValueKey('folder-drop-${widget.name}'),
position: material.DecorationPosition.foreground,
decoration: material.BoxDecoration(
borderRadius: material.BorderRadius.circular(6),
border: material.Border.all(
color: candidates.isEmpty
? material.Colors.transparent
: theme.colorScheme.primary,
width: 1.5,
),
),
child: menu,
),
);
}

Widget _buildMenu(BuildContext context, ThemeData theme) {
return ContextMenu(
items: [
MenuButton(
Expand Down
89 changes: 89 additions & 0 deletions test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/gestures.dart' show kSecondaryButton;
import 'package:flutter/widgets.dart' show Draggable, ValueKey;
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/storage/app_settings.dart';
import 'package:querya_desktop/core/storage/folders_storage.dart';
Expand Down Expand Up @@ -129,4 +130,92 @@ void main() {
await tester.runAsync(() => FoldersStorage.instance.remove('Team C'));
await app.close(tester);
});


testWidgets('a connection is dragged into a folder and out of it again',
(tester) async {
await app.launch(tester);
await tester.runAsync(() => FoldersStorage.instance.add('Team D'));
final folderId = await tester
.runAsync(() => LocalDb.instance.getFolderIdByName('Team D'));
final id = await E2eConnections.add(
tester, E2eConnections.redis('E2E Dragged'));
await tester.runAsync(FoldersStorage.instance.reload);
await E2eConnections.reloadSidebar(tester);

Future<void> dragTo(Offset from, Offset to) async {
final gesture = await tester.startGesture(from);
// Past the drag slop, then onto the target in a few steps.
await gesture.moveBy(const Offset(0, 24));
await tester.pump(const Duration(milliseconds: 50));
await gesture.moveTo(to);
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pump(const Duration(milliseconds: 300));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump(const Duration(milliseconds: 300));
}

await dragTo(
tester.getCenter(inSidebar('E2E Dragged').first),
tester.getCenter(inSidebar('Team D')),
);
final first =
await tester.runAsync(() => LocalDb.instance.getConnectionById(id));
expect(first!.folderId, folderId, reason: 'dropped on the folder');

// The panel reloads from the database after the drop (real I/O); the tile
// is under its folder once it has. Dragging before that starts from the
// old layout with the old folder.
// The panel reloads from the database after the drop (real I/O). Until it
// has, it still holds the connection outside the folder: a drag started
// then has nothing to take out. (Its tile's position says nothing here:
// folders are listed first, so a top-level tile is below them too.)
final panel =
tester.state<ConnectionsPanelState>(find.byType(ConnectionsPanel));
for (var i = 0; i < 40; i++) {
if (panel.connectionFoldersForTesting[id] == folderId) break;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump(const Duration(milliseconds: 100));
}
expect(panel.connectionFoldersForTesting[id], folderId,
reason: 'the panel reloaded after the drop');
// The folder is still unfolding (an animated, clipped height): a press on
// a half-revealed tile lands on whatever is behind it.
await tester.pump(const Duration(milliseconds: 600));
final from = tester.getCenter(inSidebar('E2E Dragged').first);
final gesture = await tester.startGesture(from);
await gesture.moveBy(const Offset(0, 24));
await tester.pump(const Duration(milliseconds: 100));
final strip = find.byKey(const ValueKey('drop_out_of_folder'));
final state =
tester.state<ConnectionsPanelState>(find.byType(ConnectionsPanel));
expect(strip, findsOneWidget,
reason: 'the drop strip shows while dragging; '
'dragging=${state.draggedConnectionIdForTesting} '
'folders=${state.connectionFoldersForTesting} id=$id '
'draggables=${find.byType(Draggable<int>).evaluate().length} '
'pressed at $from');
await gesture.moveTo(tester.getCenter(strip));
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pump(const Duration(milliseconds: 300));
// The write is real I/O: give it time before reading.
ConnectionRow? row;
for (var i = 0; i < 20; i++) {
row = await tester
.runAsync<ConnectionRow?>(() => LocalDb.instance.getConnectionById(id));
if (row!.folderId == null) break;
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump(const Duration(milliseconds: 100));
}
expect(row!.folderId, isNull, reason: 'dropped on the strip');

await E2eConnections.remove(tester, id);
await tester.runAsync(() => FoldersStorage.instance.remove('Team D'));
await app.close(tester);
});
}
Loading
Loading