Skip to content

Commit 3d4e5eb

Browse files
feat(grid): add atomic DML preview and confirmation dialog before save (#566)
Closes #566
1 parent d5fc4c2 commit 3d4e5eb

5 files changed

Lines changed: 464 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import 'package:flutter/material.dart' as material;
2+
import 'package:flutter/services.dart';
3+
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
4+
import 'package:querya_desktop/shared/widgets/app_dialog.dart';
5+
import 'package:shadcn_flutter/shadcn_flutter.dart';
6+
7+
/// Opens the DML Preview and Confirmation dialog before executing staged changes.
8+
///
9+
/// Returns `true` if user confirmed execution, or `false`/`null` if cancelled.
10+
Future<bool?> showDmlPreviewDialog({
11+
required material.BuildContext context,
12+
required TableMutationPlan plan,
13+
}) {
14+
return showAppDialog<bool>(
15+
context: context,
16+
builder: (ctx) => _DmlPreviewDialog(plan: plan),
17+
);
18+
}
19+
20+
class _DmlPreviewDialog extends material.StatefulWidget {
21+
const _DmlPreviewDialog({required this.plan});
22+
23+
final TableMutationPlan plan;
24+
25+
@override
26+
material.State<_DmlPreviewDialog> createState() => _DmlPreviewDialogState();
27+
}
28+
29+
class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> {
30+
bool _copied = false;
31+
32+
int get _updateCount =>
33+
widget.plan.statements.where((s) => s.type == MutationType.update).length;
34+
35+
int get _insertCount =>
36+
widget.plan.statements.where((s) => s.type == MutationType.insert).length;
37+
38+
int get _deleteCount =>
39+
widget.plan.statements.where((s) => s.type == MutationType.delete).length;
40+
41+
String get _dialectName {
42+
switch (widget.plan.dialect) {
43+
case SqlDialect.postgres:
44+
return 'PostgreSQL';
45+
case SqlDialect.mysql:
46+
return 'MySQL';
47+
case SqlDialect.sqlite:
48+
return 'SQLite';
49+
}
50+
}
51+
52+
Future<void> _copySql() async {
53+
final sql = widget.plan.toTransactionSql();
54+
await Clipboard.setData(ClipboardData(text: sql));
55+
if (!mounted) return;
56+
setState(() => _copied = true);
57+
await Future<void>.delayed(const Duration(seconds: 2));
58+
if (mounted) setState(() => _copied = false);
59+
}
60+
61+
@override
62+
material.Widget build(material.BuildContext context) {
63+
final theme = Theme.of(context);
64+
final cs = theme.colorScheme;
65+
final isDark = theme.brightness == Brightness.dark;
66+
final sql = widget.plan.toTransactionSql();
67+
68+
return material.Dialog(
69+
backgroundColor: cs.card,
70+
shape: material.RoundedRectangleBorder(
71+
borderRadius: material.BorderRadius.circular(8),
72+
side: material.BorderSide(color: cs.border, width: 1),
73+
),
74+
child: material.ConstrainedBox(
75+
constraints: const material.BoxConstraints(
76+
minWidth: 540,
77+
maxWidth: 680,
78+
minHeight: 380,
79+
maxHeight: 580,
80+
),
81+
child: material.Padding(
82+
padding: const material.EdgeInsets.all(18),
83+
child: material.Column(
84+
mainAxisSize: material.MainAxisSize.min,
85+
crossAxisAlignment: material.CrossAxisAlignment.stretch,
86+
children: [
87+
// Header Row
88+
material.Row(
89+
children: [
90+
material.Icon(
91+
material.Icons.save_as_rounded,
92+
size: 20,
93+
color: cs.primary,
94+
),
95+
const Gap(8),
96+
const Text('Confirm Data Changes').semiBold().large(),
97+
],
98+
),
99+
const Gap(4),
100+
const Text(
101+
'Review pending SQL mutations before applying them to the database.',
102+
).muted().small(),
103+
const Gap(14),
104+
105+
// Metadata badges row
106+
material.Wrap(
107+
spacing: 8,
108+
runSpacing: 8,
109+
crossAxisAlignment: material.WrapCrossAlignment.center,
110+
children: [
111+
// Target Table Badge
112+
material.Container(
113+
padding: const material.EdgeInsets.symmetric(
114+
horizontal: 10,
115+
vertical: 4,
116+
),
117+
decoration: material.BoxDecoration(
118+
color: cs.muted,
119+
borderRadius: material.BorderRadius.circular(6),
120+
),
121+
child: material.Row(
122+
mainAxisSize: material.MainAxisSize.min,
123+
children: [
124+
material.Icon(
125+
material.Icons.table_chart_outlined,
126+
size: 14,
127+
color: cs.foreground,
128+
),
129+
const Gap(6),
130+
Text(
131+
widget.plan.schema != null &&
132+
widget.plan.schema!.isNotEmpty
133+
? '${widget.plan.schema}.${widget.plan.tableName}'
134+
: widget.plan.tableName,
135+
).semiBold().small(),
136+
],
137+
),
138+
),
139+
140+
// Dialect Badge
141+
material.Container(
142+
padding: const material.EdgeInsets.symmetric(
143+
horizontal: 10,
144+
vertical: 4,
145+
),
146+
decoration: material.BoxDecoration(
147+
color: cs.primary.withValues(alpha: 0.12),
148+
borderRadius: material.BorderRadius.circular(6),
149+
border: material.Border.all(
150+
color: cs.primary.withValues(alpha: 0.3),
151+
),
152+
),
153+
child: Text(
154+
_dialectName,
155+
style: TextStyle(
156+
color: cs.primary,
157+
fontWeight: FontWeight.w600,
158+
fontSize: 12,
159+
),
160+
),
161+
),
162+
163+
// Changes Breakdown Pills
164+
if (_updateCount > 0)
165+
_buildCountPill(
166+
label: '$_updateCount UPDATE',
167+
color: material.Colors.amber.shade700,
168+
isDark: isDark,
169+
),
170+
if (_insertCount > 0)
171+
_buildCountPill(
172+
label: '$_insertCount INSERT',
173+
color: material.Colors.green.shade600,
174+
isDark: isDark,
175+
),
176+
if (_deleteCount > 0)
177+
_buildCountPill(
178+
label: '$_deleteCount DELETE',
179+
color: material.Colors.red.shade600,
180+
isDark: isDark,
181+
),
182+
],
183+
),
184+
const Gap(14),
185+
186+
// SQL Preview code block header
187+
material.Row(
188+
mainAxisAlignment: material.MainAxisAlignment.spaceBetween,
189+
children: [
190+
const Text('TRANSACTION SCRIPT').semiBold().xSmall(),
191+
material.InkWell(
192+
onTap: _copySql,
193+
borderRadius: material.BorderRadius.circular(4),
194+
child: material.Padding(
195+
padding: const material.EdgeInsets.symmetric(
196+
horizontal: 6,
197+
vertical: 2,
198+
),
199+
child: material.Row(
200+
mainAxisSize: material.MainAxisSize.min,
201+
children: [
202+
material.Icon(
203+
_copied
204+
? material.Icons.check_rounded
205+
: material.Icons.copy_rounded,
206+
size: 13,
207+
color: _copied
208+
? material.Colors.green
209+
: cs.mutedForeground,
210+
),
211+
const Gap(4),
212+
Text(_copied ? 'Copied' : 'Copy SQL').xSmall(),
213+
],
214+
),
215+
),
216+
),
217+
],
218+
),
219+
const Gap(6),
220+
221+
// SQL Code Preview Container
222+
material.Expanded(
223+
child: material.Container(
224+
padding: const material.EdgeInsets.all(12),
225+
decoration: material.BoxDecoration(
226+
color: isDark
227+
? const material.Color(0xFF141416)
228+
: const material.Color(0xFFF4F4F6),
229+
borderRadius: material.BorderRadius.circular(8),
230+
border: material.Border.all(
231+
color: cs.border.withValues(alpha: 0.6),
232+
),
233+
),
234+
child: material.SingleChildScrollView(
235+
child: material.SelectableText(
236+
sql,
237+
style: material.TextStyle(
238+
fontFamily: 'monospace',
239+
fontSize: 12.5,
240+
height: 1.45,
241+
color: isDark
242+
? const material.Color(0xFFE2E8F0)
243+
: const material.Color(0xFF1E293B),
244+
),
245+
),
246+
),
247+
),
248+
),
249+
const Gap(12),
250+
251+
// Atomic Notice
252+
material.Container(
253+
padding: const material.EdgeInsets.symmetric(
254+
horizontal: 10,
255+
vertical: 8,
256+
),
257+
decoration: material.BoxDecoration(
258+
color: cs.muted.withValues(alpha: 0.4),
259+
borderRadius: material.BorderRadius.circular(6),
260+
),
261+
child: material.Row(
262+
children: [
263+
material.Icon(
264+
material.Icons.info_outline,
265+
size: 15,
266+
color: cs.mutedForeground,
267+
),
268+
const Gap(8),
269+
const material.Expanded(
270+
child: Text(
271+
'All mutations will be executed atomically in a single transaction.',
272+
),
273+
),
274+
],
275+
),
276+
),
277+
const Gap(16),
278+
279+
// Actions
280+
material.Row(
281+
mainAxisAlignment: material.MainAxisAlignment.end,
282+
children: [
283+
OutlineButton(
284+
onPressed: () => material.Navigator.of(context).pop(false),
285+
child: const Text('Cancel'),
286+
),
287+
const Gap(8),
288+
PrimaryButton(
289+
onPressed: () => material.Navigator.of(context).pop(true),
290+
leading: const material.Icon(
291+
material.Icons.save_outlined,
292+
size: 16,
293+
),
294+
child: const Text('Apply Changes'),
295+
),
296+
],
297+
),
298+
],
299+
),
300+
),
301+
),
302+
);
303+
}
304+
305+
material.Widget _buildCountPill({
306+
required String label,
307+
required material.Color color,
308+
required bool isDark,
309+
}) {
310+
return material.Container(
311+
padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
312+
decoration: material.BoxDecoration(
313+
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
314+
borderRadius: material.BorderRadius.circular(6),
315+
border: material.Border.all(
316+
color: color.withValues(alpha: isDark ? 0.45 : 0.3),
317+
),
318+
),
319+
child: Text(
320+
label,
321+
style: TextStyle(
322+
color: color,
323+
fontWeight: FontWeight.w600,
324+
fontSize: 11,
325+
),
326+
),
327+
);
328+
}
329+
}

‎lib/features/mysql/mysql_sql_workspace.dart‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:querya_desktop/core/storage/local_db.dart';
1616
import 'package:querya_desktop/features/settings/preferences_dialog.dart';
1717
import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart';
1818
import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart';
19+
import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart';
1920
import 'package:querya_desktop/features/main_screen/query_editor_tab.dart';
2021
import 'package:querya_desktop/features/main_screen/results_tab.dart';
2122
import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart';
@@ -292,6 +293,15 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
292293
return;
293294
}
294295

296+
final confirmed = await showDmlPreviewDialog(
297+
context: context,
298+
plan: plan,
299+
);
300+
if (confirmed != true) {
301+
setState(() => _savingChanges = false);
302+
return;
303+
}
304+
295305
await _ensureLease();
296306
final conn = _lease?.connection;
297307
if (conn == null || !conn.isConnected) {

‎lib/features/postgresql/postgres_sql_workspace.dart‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart';
2020
import 'package:querya_desktop/features/settings/preferences_dialog.dart';
2121
import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart';
2222
import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart';
23+
import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart';
2324
import 'package:querya_desktop/features/main_screen/query_editor_tab.dart';
2425
import 'package:querya_desktop/features/main_screen/results_tab.dart';
2526
import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart';
@@ -444,6 +445,15 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
444445
return;
445446
}
446447

448+
final confirmed = await showDmlPreviewDialog(
449+
context: context,
450+
plan: plan,
451+
);
452+
if (confirmed != true) {
453+
setState(() => _savingChanges = false);
454+
return;
455+
}
456+
447457
await _ensureLease();
448458
final conn = _lease?.connection;
449459
if (conn == null || !conn.isConnected) {

0 commit comments

Comments
 (0)