From 233991ad42537048da69a543fe7e50ac3087e027 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 18:33:14 +0300 Subject: [PATCH] ux(grid): open tables in view mode; enter editing explicitly (Closes #991) Table Browser put every table with a primary key straight into editing: the edit toolbar was shown and any cell was editable on double-click, which is disorienting when you only want to read data and makes accidental edits easy. Tables now open in view mode. An Edit button in the table header (Ctrl+E) turns on edit mode and shows the edit toolbar; it becomes Done, which leaves edit mode after asking to discard pending edits. Save and Revert keep edit mode on, Refresh keeps it too, and opening another table starts in view mode again. When a table cannot be edited (view, no primary key, read-only session, schema unavailable) the button is disabled with the reason as its tooltip. Applies to the PostgreSQL, MySQL, SQLite and extension Table Browsers. The PostgreSQL toolbar slot left empty by the previous change is renamed from pendingActions to editAction and hosts the new button. --- .../extensions/extension_table_toolbar.dart | 8 + .../extensions/extension_table_view.dart | 316 +++++++++++------- lib/features/mysql/mysql_table_view.dart | 69 +++- .../postgresql/postgres_table_toolbar.dart | 11 +- .../postgresql/postgres_table_view.dart | 68 +++- lib/features/sqlite/sqlite_table_view.dart | 69 +++- .../workspace/table_view_staging.dart | 55 +++ .../postgres_table_toolbar_layout_test.dart | 2 +- .../workspace/table_view_staging_test.dart | 82 +++++ 9 files changed, 549 insertions(+), 131 deletions(-) diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index 0c25940..f23805a 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -25,6 +25,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { this.onNavigateHome, this.onRestartDriver, this.isRestarting = false, + this.editAction, }); final String title; @@ -47,6 +48,9 @@ class ExtensionTableToolbar extends material.StatelessWidget { final VoidCallback? onRestartDriver; final bool isRestarting; + /// View / edit mode toggle ([TableEditModeButton]). + final material.Widget? editAction; + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -125,6 +129,10 @@ class ExtensionTableToolbar extends material.StatelessWidget { ), ), const Gap(6), + if (editAction != null) ...[ + editAction!, + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: onOpenDdl, diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index b4a2dac..aaf5ad5 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; @@ -56,6 +57,9 @@ class _ExtensionTableViewState extends material.State { bool _schemaLoaded = false; Object? _schemaError; List _primaryKeys = const []; + + /// Tables open in view mode; editing is switched on explicitly. + bool _editMode = false; bool _isSaving = false; bool _restartingDriver = false; @@ -136,6 +140,7 @@ class _ExtensionTableViewState extends material.State { if (!mounted) return; _stagingBuffer?.dispose(); _stagingBuffer = null; + _editMode = false; _schemaLoaded = false; _schemaError = null; _offset = 0; @@ -159,6 +164,49 @@ class _ExtensionTableViewState extends material.State { bool get _isDirty => _stagingBuffer?.isDirty == true; + /// Whether this table could be edited (driver mutations, PK, not a view). + bool get _canEdit => tableViewEditingEnabled( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: _capabilities?.supportsMutations != true, + schemaError: _schemaError, + ); + + String? _editDisabledReason() => tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + readOnly: _capabilities != null && !_capabilities!.supportsMutations, + schemaError: _schemaError, + ); + + void _enterEditMode() { + if (!_canEdit || _editMode) return; + setState(() { + _editMode = true; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columns, + rows: _rows, + enabled: true, + primaryKeys: _primaryKeys, + ); + }); + } + + Future _exitEditMode() async { + if (!_editMode) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _editMode = false; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + }); + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -303,18 +351,11 @@ class _ExtensionTableViewState extends material.State { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; - final editingEnabled = tableViewEditingEnabled( - isView: widget.isView, - customSqlActive: false, - hasPrimaryKey: _primaryKeys.isNotEmpty, - readOnly: _capabilities?.supportsMutations != true, - schemaError: _schemaError, - ); _stagingBuffer = replaceTableViewStagingBuffer( previous: _stagingBuffer, columns: _columns, rows: _rows, - enabled: editingEnabled, + enabled: _canEdit && _editMode, primaryKeys: _primaryKeys, ); _updateStatusLine(); @@ -491,7 +532,8 @@ class _ExtensionTableViewState extends material.State { await showAppDialog( context: context, builder: (ctx) => QueryaDialogCard( - constraints: const material.BoxConstraints(maxWidth: 640, maxHeight: 500), + constraints: + const material.BoxConstraints(maxWidth: 640, maxHeight: 500), child: material.Padding( padding: const material.EdgeInsets.all(20), child: material.Column( @@ -504,7 +546,8 @@ class _ExtensionTableViewState extends material.State { const Gap(16), material.Expanded( child: material.Container( - decoration: SqlEditorChrome.inlineFieldDecorationFromContext(ctx), + decoration: + SqlEditorChrome.inlineFieldDecorationFromContext(ctx), child: QueryaCodeEditor( controller: material.TextEditingController(text: ddlText), language: QueryaCodeLanguage.sql, @@ -566,128 +609,155 @@ class _ExtensionTableViewState extends material.State { material.Widget build(material.BuildContext context) { final kind = widget.isView ? 'View' : 'Table'; - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - ExtensionTableToolbar( - title: '$kind · ${widget.database}.${widget.tableName}', - paginationLabel: _statusLine ?? 'Loading...', - tableIcon: widget.isView - ? material.Icons.view_list_rounded - : material.Icons.table_chart_outlined, - loading: _loading, - canGoPrevious: _canGoBack && !_loading, - canGoNext: _canGoForward && !_loading, - onNavigateHome: widget.onNavigateHome != null - ? () => unawaited(() async { - if (!await _confirmDiscardIfNeeded()) return; - if (!mounted) return; - widget.onNavigateHome!(); - }()) - : null, - filterActive: _filterActive || _filterController.text.isNotEmpty, - filterText: _filterController.text, - onToggleFilter: () { - setState(() { - _filterActive = !_filterActive; - }); - }, - onOpenDdl: _openDdlDialog, - onGoPrevious: _previousPage, - onGoNext: _nextPage, - onRefresh: () => unawaited(_onRefresh()), - onRestartDriver: () => unawaited(_restartDriver()), - isRestarting: _restartingDriver, - onCopyFormat: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: _columns, - rows: _rows, - ); - }()); - }, - onSaveFormat: (format) { - unawaited(() async { - final outcome = await DataExportService.saveToFile( - format, - columns: _columns, - rows: _rows, - ); - if (!context.mounted) return; - if (outcome == SaveExportOutcome.error) { - await _showSaveFileErrorDialog(context); - } - }()); - }, - ), - if (_filterActive) - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.3), - border: material.Border( - bottom: material.BorderSide( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.3), + void toggleEditMode() { + if (_editMode) { + unawaited(_exitEditMode()); + } else { + _enterEditMode(); + } + } + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): + toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): + toggleEditMode, + }, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + ExtensionTableToolbar( + title: '$kind · ${widget.database}.${widget.tableName}', + paginationLabel: _statusLine ?? 'Loading...', + tableIcon: widget.isView + ? material.Icons.view_list_rounded + : material.Icons.table_chart_outlined, + loading: _loading, + canGoPrevious: _canGoBack && !_loading, + canGoNext: _canGoForward && !_loading, + onNavigateHome: widget.onNavigateHome != null + ? () => unawaited(() async { + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + widget.onNavigateHome!(); + }()) + : null, + filterActive: _filterActive || _filterController.text.isNotEmpty, + filterText: _filterController.text, + onToggleFilter: () { + setState(() { + _filterActive = !_filterActive; + }); + }, + onOpenDdl: _openDdlDialog, + onGoPrevious: _previousPage, + onGoNext: _nextPage, + onRefresh: () => unawaited(_onRefresh()), + onRestartDriver: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + editAction: widget.isView + ? null + : TableEditModeButton( + editMode: _editMode, + canEdit: _canEdit, + busy: _loading, + disabledReason: _editDisabledReason(), + onEdit: _enterEditMode, + onDone: () => unawaited(_exitEditMode()), + ), + onCopyFormat: (format) { + unawaited(() async { + await DataExportService.copyToClipboard( + format, + columns: _columns, + rows: _rows, + ); + }()); + }, + onSaveFormat: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: _columns, + rows: _rows, + ); + if (!context.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + ), + if (_filterActive) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: material.BoxDecoration( + color: + Theme.of(context).colorScheme.muted.withValues(alpha: 0.3), + border: material.Border( + bottom: material.BorderSide( + color: Theme.of(context) + .colorScheme + .border + .withValues(alpha: 0.3), + ), ), ), - ), - child: material.Row( - children: [ - const material.Text('WHERE ').semiBold().small(), - const Gap(8), - material.Expanded( - child: material.TextField( - controller: _filterController, - decoration: const material.InputDecoration( - hintText: "e.g. id > 100 AND status = 'active'", - isDense: true, - border: material.OutlineInputBorder(), + child: material.Row( + children: [ + const material.Text('WHERE ').semiBold().small(), + const Gap(8), + material.Expanded( + child: material.TextField( + controller: _filterController, + decoration: const material.InputDecoration( + hintText: "e.g. id > 100 AND status = 'active'", + isDense: true, + border: material.OutlineInputBorder(), + ), + onSubmitted: (_) => _applyFilter(), ), - onSubmitted: (_) => _applyFilter(), ), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: _applyFilter, - child: const Text('Apply'), - ), - if (_filterController.text.isNotEmpty) ...[ - const Gap(6), - GhostButton( + const Gap(8), + OutlineButton( size: ButtonSize.small, - onPressed: _clearFilter, - child: const Text('Clear'), + onPressed: _applyFilter, + child: const Text('Apply'), ), + if (_filterController.text.isNotEmpty) ...[ + const Gap(6), + GhostButton( + size: ButtonSize.small, + onPressed: _clearFilter, + child: const Text('Clear'), + ), + ], ], - ], + ), + ), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + statusLine: _statusLine, + showExportToolbar: false, + stagingBuffer: _stagingBuffer, + onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, + isSaving: _isSaving, + errorAction: _isDriverError + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null, ), ), - material.Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _loading, - statusLine: _statusLine, - showExportToolbar: false, - stagingBuffer: _stagingBuffer, - onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, - isSaving: _isSaving, - errorAction: _isDriverError - ? ExtensionDriverRecoveryBanner( - onRestart: () => unawaited(_restartDriver()), - isRestarting: _restartingDriver, - ) - : null, - ), - ), - ], + ], + ), ); } } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 9606e39..64287c5 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -72,7 +72,11 @@ class _MysqlTableViewState extends material.State { bool get _isDirty => _stagingBuffer?.isDirty ?? false; - bool get _editingEnabled => tableViewEditingEnabled( + /// Tables open in view mode; editing is switched on explicitly. + bool _editMode = false; + + /// Whether this table could be edited (PK, not a view, writable). + bool get _canEdit => tableViewEditingEnabled( isView: widget.isView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, @@ -80,6 +84,9 @@ class _MysqlTableViewState extends material.State { schemaError: _schemaError, ); + /// Edit mode is on and the table is editable. + bool get _editingEnabled => _canEdit && _editMode; + String _qualifiedFrom() { final d = MysqlConnection.quoteIdentifier(widget.database); final t = MysqlConnection.quoteIdentifier(widget.tableName); @@ -172,6 +179,7 @@ class _MysqlTableViewState extends material.State { } Future _connectAndLoad() async { + _editMode = false; _disconnectCurrent(); if (!mounted) return; setState(() { @@ -256,6 +264,57 @@ class _MysqlTableViewState extends material.State { } } + String? _editDisabledReason() => tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + readOnly: widget.isReadOnly, + schemaError: _schemaError, + ); + + void _enterEditMode() { + if (!_canEdit || _editMode) return; + setState(() { + _editMode = true; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + primaryKeys: _primaryKeys, + ); + }); + } + + Future _exitEditMode() async { + if (!_editMode) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _editMode = false; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + }); + } + + void _toggleEditMode() { + if (_editMode) { + unawaited(_exitEditMode()); + } else { + _enterEditMode(); + } + } + + material.Widget _editModeButton() => TableEditModeButton( + editMode: _editMode, + canEdit: _canEdit, + busy: _loading || _isSaving, + disabledReason: _editDisabledReason(), + onEdit: _enterEditMode, + onDone: () => unawaited(_exitEditMode()), + ); + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -705,6 +764,10 @@ class _MysqlTableViewState extends material.State { ), ), const Gap(6), + if (!widget.isView) ...[ + _editModeButton(), + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: _openSqlEditor, @@ -779,6 +842,10 @@ class _MysqlTableViewState extends material.State { return material.CallbackShortcuts( bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): + _toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): + _toggleEditMode, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_loading) unawaited(_onRefresh()); }, diff --git a/lib/features/postgresql/postgres_table_toolbar.dart b/lib/features/postgresql/postgres_table_toolbar.dart index e147d7c..1b49514 100644 --- a/lib/features/postgresql/postgres_table_toolbar.dart +++ b/lib/features/postgresql/postgres_table_toolbar.dart @@ -21,7 +21,7 @@ class PostgresTableToolbar extends material.StatelessWidget { required this.onGoNext, required this.onRefresh, this.onNavigateHome, - this.pendingActions, + this.editAction, }); final String title; @@ -41,8 +41,8 @@ class PostgresTableToolbar extends material.StatelessWidget { final VoidCallback onRefresh; final VoidCallback? onNavigateHome; - /// Compact pending-change badge + Save / Revert (Table Browser staging). - final material.Widget? pendingActions; + /// View / edit mode toggle ([TableEditModeButton]). + final material.Widget? editAction; @override material.Widget build(material.BuildContext context) { @@ -120,7 +120,10 @@ class PostgresTableToolbar extends material.StatelessWidget { ), ), const Gap(6), - if (pendingActions != null) pendingActions!, + if (editAction != null) ...[ + editAction!, + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: onOpenSql, diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index f09df63..6d46b8c 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -80,7 +80,11 @@ class _PostgresTableViewState extends material.State { bool get _isDirty => _stagingBuffer?.isDirty ?? false; - bool get _editingEnabled => tableViewEditingEnabled( + /// Tables open in view mode; editing is switched on explicitly. + bool _editMode = false; + + /// Whether this table could be edited (PK, not a view, writable). + bool get _canEdit => tableViewEditingEnabled( isView: widget.isView, isMaterializedView: widget.isMaterializedView, customSqlActive: _customSqlActive, @@ -88,6 +92,9 @@ class _PostgresTableViewState extends material.State { schemaError: _schemaError, ); + /// Edit mode is on and the table is editable. + bool get _editingEnabled => _canEdit && _editMode; + @override void initState() { super.initState(); @@ -148,6 +155,7 @@ class _PostgresTableViewState extends material.State { } Future _connectAndLoad() async { + _editMode = false; _disconnectCurrent(); if (!mounted) return; setState(() { @@ -210,6 +218,57 @@ class _PostgresTableViewState extends material.State { } } + String? _editDisabledReason() => tableViewEditDisabledReason( + isView: widget.isView, + isMaterializedView: widget.isMaterializedView, + customSqlActive: _customSqlActive, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + schemaError: _schemaError, + ); + + void _enterEditMode() { + if (!_canEdit || _editMode) return; + setState(() { + _editMode = true; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + primaryKeys: _primaryKeys, + ); + }); + } + + Future _exitEditMode() async { + if (!_editMode) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _editMode = false; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + }); + } + + void _toggleEditMode() { + if (_editMode) { + unawaited(_exitEditMode()); + } else { + _enterEditMode(); + } + } + + material.Widget _editModeButton() => TableEditModeButton( + editMode: _editMode, + canEdit: _canEdit, + busy: _loading || _isSaving, + disabledReason: _editDisabledReason(), + onEdit: _enterEditMode, + onDone: () => unawaited(_exitEditMode()), + ); + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -667,6 +726,9 @@ class _PostgresTableViewState extends material.State { onGoPrevious: _goToPreviousPage, onGoNext: _goToNextPage, onRefresh: () => unawaited(_onRefresh()), + editAction: widget.isView || widget.isMaterializedView + ? null + : _editModeButton(), ); final buffer = _stagingBuffer; if (buffer == null) return toolbar(); @@ -682,6 +744,10 @@ class _PostgresTableViewState extends material.State { return material.CallbackShortcuts( bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): + _toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): + _toggleEditMode, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_loading) unawaited(_onRefresh()); }, diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index e1a6683..38f6dd8 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -65,7 +65,11 @@ class _SqliteTableViewState extends material.State { bool get _readOnly => widget.isReadOnly || widget.connectionRow.useSSL; - bool get _editingEnabled => tableViewEditingEnabled( + /// Tables open in view mode; editing is switched on explicitly. + bool _editMode = false; + + /// Whether this table could be edited (PK, not a view, writable). + bool get _canEdit => tableViewEditingEnabled( isView: widget.isView, customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, @@ -73,6 +77,9 @@ class _SqliteTableViewState extends material.State { schemaError: _schemaError, ); + /// Edit mode is on and the table is editable. + bool get _editingEnabled => _canEdit && _editMode; + String _qualifiedFrom() { return SqliteConnection.quoteIdentifier(widget.tableName); } @@ -160,6 +167,7 @@ class _SqliteTableViewState extends material.State { } Future _connectAndLoad() async { + _editMode = false; _disconnectCurrent(); if (!mounted) return; setState(() { @@ -209,6 +217,57 @@ class _SqliteTableViewState extends material.State { } } + String? _editDisabledReason() => tableViewEditDisabledReason( + isView: widget.isView, + customSqlActive: false, + hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaLoaded: _schemaLoaded, + readOnly: _readOnly, + schemaError: _schemaError, + ); + + void _enterEditMode() { + if (!_canEdit || _editMode) return; + setState(() { + _editMode = true; + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + primaryKeys: _primaryKeys, + ); + }); + } + + Future _exitEditMode() async { + if (!_editMode) return; + if (!await _confirmDiscardIfNeeded()) return; + if (!mounted) return; + setState(() { + _editMode = false; + _stagingBuffer?.dispose(); + _stagingBuffer = null; + }); + } + + void _toggleEditMode() { + if (_editMode) { + unawaited(_exitEditMode()); + } else { + _enterEditMode(); + } + } + + material.Widget _editModeButton() => TableEditModeButton( + editMode: _editMode, + canEdit: _canEdit, + busy: _loading || _isSaving, + disabledReason: _editDisabledReason(), + onEdit: _enterEditMode, + onDone: () => unawaited(_exitEditMode()), + ); + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -646,6 +705,10 @@ class _SqliteTableViewState extends material.State { ), ), const Gap(6), + if (!widget.isView) ...[ + _editModeButton(), + const Gap(4), + ], OutlineButton( size: ButtonSize.small, onPressed: _loading @@ -706,6 +769,10 @@ class _SqliteTableViewState extends material.State { return material.CallbackShortcuts( bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyE, control: true): + _toggleEditMode, + const material.SingleActivator(LogicalKeyboardKey.keyE, meta: true): + _toggleEditMode, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_loading) unawaited(_onRefresh()); }, diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index ef879e0..9c1b456 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -190,6 +190,61 @@ void expectDmlMatchedRows(int affectedRows) { ); } +/// Header toggle between browsing a table and editing its rows. +/// +/// Tables open in view mode; [onEdit] turns editing on (edit toolbar, editable +/// cells) and [onDone] turns it off. When the table cannot be edited the +/// button is disabled and [disabledReason] explains why. +class TableEditModeButton extends material.StatelessWidget { + const TableEditModeButton({ + super.key, + required this.editMode, + required this.canEdit, + required this.onEdit, + required this.onDone, + this.disabledReason, + this.busy = false, + }); + + final bool editMode; + final bool canEdit; + final material.VoidCallback onEdit; + final material.VoidCallback onDone; + final String? disabledReason; + + /// Loading or saving: the toggle waits. + final bool busy; + + @override + material.Widget build(material.BuildContext context) { + if (editMode) { + return material.Tooltip( + message: 'Stop editing (Ctrl+E)', + waitDuration: const Duration(milliseconds: 400), + child: PrimaryButton( + size: ButtonSize.small, + onPressed: busy ? null : onDone, + leading: const material.Icon(material.Icons.check_rounded, size: 15), + child: const Text('Done'), + ), + ); + } + final enabled = canEdit && !busy; + return material.Tooltip( + message: canEdit + ? 'Edit rows (Ctrl+E)' + : (disabledReason ?? 'This table cannot be edited'), + waitDuration: const Duration(milliseconds: 400), + child: OutlineButton( + size: ButtonSize.small, + onPressed: enabled ? onEdit : null, + leading: const material.Icon(material.Icons.edit_rounded, size: 14), + child: const Text('Edit'), + ), + ); + } +} + /// Success toast after Save, e.g. `1 change saved`, `3 changes saved`. String tableViewSavedMessage(int count) => '$count ${count == 1 ? 'change' : 'changes'} saved'; diff --git a/test/features/postgresql/postgres_table_toolbar_layout_test.dart b/test/features/postgresql/postgres_table_toolbar_layout_test.dart index 9797f44..6ff8abf 100644 --- a/test/features/postgresql/postgres_table_toolbar_layout_test.dart +++ b/test/features/postgresql/postgres_table_toolbar_layout_test.dart @@ -108,7 +108,7 @@ void main() { onGoPrevious: () {}, onGoNext: () {}, onRefresh: () {}, - pendingActions: material.Row( + editAction: material.Row( mainAxisSize: material.MainAxisSize.min, children: [ const material.Text('2 pending changes'), diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 4fcf01e..cd79bc0 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -559,4 +559,86 @@ void main() { expect(result, isTrue); }); }); + + group('TableEditModeButton', () { + Future pump(WidgetTester tester, material.Widget child) => + tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Scaffold(body: material.Center(child: child)), + ), + ); + + testWidgets('view mode offers Edit and calls onEdit', (tester) async { + var edits = 0; + await pump( + tester, + TableEditModeButton( + editMode: false, + canEdit: true, + onEdit: () => edits++, + onDone: () {}, + ), + ); + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Done'), findsNothing); + expect(find.byTooltip('Edit rows (Ctrl+E)'), findsOneWidget); + await tester.tap(find.text('Edit')); + expect(edits, 1); + }); + + testWidgets('edit mode offers Done and calls onDone', (tester) async { + var done = 0; + await pump( + tester, + TableEditModeButton( + editMode: true, + canEdit: true, + onEdit: () {}, + onDone: () => done++, + ), + ); + expect(find.text('Done'), findsOneWidget); + expect(find.text('Edit'), findsNothing); + await tester.tap(find.text('Done')); + expect(done, 1); + }); + + testWidgets('disabled when the table cannot be edited, with the reason', + (tester) async { + var edits = 0; + await pump( + tester, + TableEditModeButton( + editMode: false, + canEdit: false, + disabledReason: 'Cannot edit: no primary key detected', + onEdit: () => edits++, + onDone: () {}, + ), + ); + expect( + find.byTooltip('Cannot edit: no primary key detected'), + findsOneWidget, + ); + await tester.tap(find.text('Edit')); + expect(edits, 0); + }); + + testWidgets('busy blocks the toggle', (tester) async { + var edits = 0; + await pump( + tester, + TableEditModeButton( + editMode: false, + canEdit: true, + busy: true, + onEdit: () => edits++, + onDone: () {}, + ), + ); + await tester.tap(find.text('Edit')); + expect(edits, 0); + }); + }); }