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); + }); + }); }