diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart new file mode 100644 index 00000000..e8e66be1 --- /dev/null +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Standard toolbar for [ExtensionTableView] with title, pagination chip, DDL inspection, custom filter toggle, and navigation. +class ExtensionTableToolbar extends material.StatelessWidget { + const ExtensionTableToolbar({ + super.key, + required this.title, + required this.paginationLabel, + required this.tableIcon, + required this.loading, + required this.canGoPrevious, + required this.canGoNext, + required this.filterActive, + required this.filterText, + required this.onToggleFilter, + required this.onOpenDdl, + required this.onGoPrevious, + required this.onGoNext, + required this.onRefresh, + this.onCancelQuery, + }); + + final String title; + final String paginationLabel; + final material.IconData tableIcon; + final bool loading; + final bool canGoPrevious; + final bool canGoNext; + final bool filterActive; + final String filterText; + final VoidCallback onToggleFilter; + final VoidCallback onOpenDdl; + final VoidCallback onGoPrevious; + final VoidCallback onGoNext; + final VoidCallback onRefresh; + final VoidCallback? onCancelQuery; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isFiltered = filterActive || filterText.trim().isNotEmpty; + + return material.Container( + padding: const material.EdgeInsets.fromLTRB(16, 10, 16, 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + material.Icon(tableIcon, size: 18, color: cs.primary), + const Gap(8), + material.Expanded( + child: material.Text( + title, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + material.Expanded( + flex: 2, + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + minWidth: constraints.maxWidth, + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + paginationLabel, + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + const Gap(6), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenDdl, + leading: const material.Icon( + material.Icons.code_rounded, + size: 16, + ), + child: const Text('DDL'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: onToggleFilter, + leading: material.Icon( + isFiltered + ? material.Icons.filter_alt_rounded + : material.Icons.filter_alt_outlined, + size: 15, + ), + child: Text(isFiltered ? 'Filter (active)' : 'Filter'), + ), + if (loading && onCancelQuery != null) ...[ + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: onCancelQuery, + leading: const material.Icon( + material.Icons.cancel_outlined, + size: 15, + ), + child: const Text('Cancel'), + ), + ], + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: canGoPrevious ? onGoPrevious : null, + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Back'), + ), + const Gap(4), + OutlineButton( + size: ButtonSize.small, + onPressed: canGoNext ? onGoNext : null, + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: loading ? null : onRefresh, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 9a840926..f4733430 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -3,12 +3,13 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; const _defaultPageSize = 200; -/// Paginated data browser for extension driver tables and views. +/// Paginated data browser for extension driver tables and views with async count and toolbar. class ExtensionTableView extends material.StatefulWidget { const ExtensionTableView({ super.key, @@ -38,13 +39,21 @@ class _ExtensionTableViewState extends material.State { int? _totalRows; String? _statusLine; + bool _filterActive = false; + final _filterController = material.TextEditingController(); + String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; + String get _whereClause { + final text = _filterController.text.trim(); + return text.isEmpty ? '' : ' WHERE $text'; + } + @override void initState() { super.initState(); - unawaited(_loadPage()); + unawaited(_loadPage(refreshCount: true)); } @override @@ -54,31 +63,84 @@ class _ExtensionTableViewState extends material.State { oldWidget.database != widget.database || oldWidget.tableName != widget.tableName) { _offset = 0; - unawaited(_loadPage()); + _totalRows = null; + _filterController.clear(); + _filterActive = false; + unawaited(_loadPage(refreshCount: true)); } } - Future _loadPage({bool refreshCount = false}) async { - if (!mounted) return; - setState(() { - _loading = true; - _error = null; - }); + @override + void dispose() { + _filterController.dispose(); + super.dispose(); + } + + void _updateStatusLine() { + final total = _totalRows; + final shownFrom = _rows.isEmpty ? 0 : _offset + 1; + final shownTo = _offset + _rows.length; + if (total == null) { + _statusLine = _loading + ? 'Loading data...' + : 'Showing $shownTo row(s) (Calculating count...).'; + } else { + _statusLine = 'Rows $shownFrom–$shownTo of $total.'; + } + } + Future _fetchCountAsync({required bool refresh}) async { + if (!refresh && _totalRows != null) return; try { - if (refreshCount || _totalRows == null) { + final countQuery = + 'SELECT count(*) AS cnt FROM $_qualifiedName$_whereClause'; + final countResult = await ExtensionDriverSession.instance.query( + widget.connectionRow, + countQuery, + ); + if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { + if (!mounted) return; + setState(() { + _totalRows = int.tryParse(countResult.rows.first.first); + _updateStatusLine(); + }); + return; + } + } catch (_) { + // Fallback for drivers that only support count() without asterisk + try { + final fallbackQuery = + 'SELECT count() AS cnt FROM $_qualifiedName$_whereClause'; final countResult = await ExtensionDriverSession.instance.query( widget.connectionRow, - 'SELECT count() AS cnt FROM $_qualifiedName', + fallbackQuery, ); if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { - _totalRows = int.tryParse(countResult.rows.first.first); + if (!mounted) return; + setState(() { + _totalRows = int.tryParse(countResult.rows.first.first); + _updateStatusLine(); + }); } + } catch (_) { + // Ignore count errors on stream or schema tables that do not support count queries } + } + } + + Future _loadPage({bool refreshCount = false}) async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + if (refreshCount) _totalRows = null; + _updateStatusLine(); + }); + try { final dataResult = await ExtensionDriverSession.instance.query( widget.connectionRow, - 'SELECT * FROM $_qualifiedName LIMIT ${widget.pageSize} OFFSET $_offset', + 'SELECT * FROM $_qualifiedName$_whereClause LIMIT ${widget.pageSize} OFFSET $_offset', ); if (!mounted) return; @@ -86,22 +148,88 @@ class _ExtensionTableViewState extends material.State { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; - final total = _totalRows; - final shownFrom = _rows.isEmpty ? 0 : _offset + 1; - final shownTo = _offset + _rows.length; - _statusLine = total == null - ? 'Showing $shownTo row(s).' - : 'Rows $shownFrom–$shownTo of $total.'; + _updateStatusLine(); }); + + unawaited(_fetchCountAsync(refresh: refreshCount || _totalRows == null)); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _loading = false; + _updateStatusLine(); }); } } + void _applyFilter() { + _offset = 0; + _totalRows = null; + unawaited(_loadPage(refreshCount: true)); + } + + void _clearFilter() { + _filterController.clear(); + _offset = 0; + _totalRows = null; + unawaited(_loadPage(refreshCount: true)); + } + + Future _openDdlDialog() async { + material.showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => const material.Center( + child: material.CircularProgressIndicator(), + ), + ); + + try { + final meta = await ExtensionDriverSession.instance.getObjectMetadata( + widget.connectionRow, + nodeId: widget.tableName, + nodeType: widget.isView ? 'view' : 'table', + ); + if (!mounted) return; + material.Navigator.of(context).pop(); + + final ddlText = meta.ddl?.trim().isNotEmpty == true + ? meta.ddl! + : '-- No DDL metadata returned by extension driver for ${widget.tableName}\nSELECT * FROM $_qualifiedName LIMIT 10;'; + + await material.showDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: material.Text( + '${widget.isView ? "View" : "Table"} DDL · ${widget.tableName}'), + content: material.SizedBox( + width: 600, + height: 400, + child: material.SingleChildScrollView( + child: material.SelectableText( + ddlText, + style: const material.TextStyle( + fontFamily: 'monospace', fontSize: 13), + ), + ), + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('Close'), + ), + ], + ), + ); + } catch (e) { + if (!mounted) return; + material.Navigator.of(context).pop(); + material.ScaffoldMessenger.of(context).showSnackBar( + material.SnackBar(content: material.Text('Failed to fetch DDL: $e')), + ); + } + } + bool get _canGoBack => _offset > 0; bool get _canGoForward { @@ -124,64 +252,84 @@ class _ExtensionTableViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); final kind = widget.isView ? 'View' : 'Table'; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.5), - border: material.Border( - bottom: material.BorderSide( - color: theme.colorScheme.border.withValues(alpha: 0.3), + 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, + filterActive: _filterActive || _filterController.text.isNotEmpty, + filterText: _filterController.text, + onToggleFilter: () { + setState(() { + _filterActive = !_filterActive; + }); + }, + onOpenDdl: _openDdlDialog, + onGoPrevious: _previousPage, + onGoNext: _nextPage, + onRefresh: () => _loadPage(refreshCount: true), + ), + 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(), + ), + onSubmitted: (_) => _applyFilter(), + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _applyFilter, + child: const Text('Apply'), + ), + if (_filterController.text.isNotEmpty) ...[ + const Gap(6), + GhostButton( + size: ButtonSize.small, + onPressed: _clearFilter, + child: const Text('Clear'), + ), + ], + ], + ), ), - child: material.Row( - children: [ - material.Expanded( - child: Text('$kind · ${widget.database}.${widget.tableName}') - .semiBold() - .small(), - ), - OutlineButton( - size: ButtonSize.small, - onPressed: _loading ? null : () => _loadPage(refreshCount: true), - child: const Text('Refresh'), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: _canGoBack && !_loading ? _previousPage : null, - child: const Text('Previous'), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: _canGoForward && !_loading ? _nextPage : null, - child: const Text('Next'), - ), - ], - ), - ), - if (_statusLine != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(12, 8, 12, 0), - child: Text(_statusLine!).muted().xSmall(), - ), - const Divider(height: 1), material.Expanded( child: ResultsTab( columns: _columns, rows: _rows, errorMessage: _error, isLoading: _loading, + statusLine: _statusLine, ), ), ], diff --git a/test/features/extensions/extension_table_toolbar_test.dart b/test/features/extensions/extension_table_toolbar_test.dart new file mode 100644 index 00000000..c7e66457 --- /dev/null +++ b/test/features/extensions/extension_table_toolbar_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +void main() { + group('ExtensionTableToolbar', () { + testWidgets('renders title, pagination chip, and buttons correctly', (tester) async { + bool ddlOpened = false; + bool filterToggled = false; + bool refreshed = false; + bool nextClicked = false; + bool prevClicked = false; + + await tester.pumpWidget( + material.MaterialApp( + theme: material.ThemeData.light(), + home: material.Scaffold( + body: ShadcnApp( + home: ExtensionTableToolbar( + title: 'Table · analytics.events', + paginationLabel: 'Rows 1–200 of 5,000', + tableIcon: material.Icons.table_chart_outlined, + loading: false, + canGoPrevious: true, + canGoNext: true, + filterActive: false, + filterText: '', + onToggleFilter: () => filterToggled = true, + onOpenDdl: () => ddlOpened = true, + onGoPrevious: () => prevClicked = true, + onGoNext: () => nextClicked = true, + onRefresh: () => refreshed = true, + ), + ), + ), + ), + ); + + expect(find.text('Table · analytics.events'), findsOneWidget); + expect(find.text('Rows 1–200 of 5,000'), findsOneWidget); + expect(find.text('DDL'), findsOneWidget); + expect(find.text('Filter'), findsOneWidget); + expect(find.text('Back'), findsOneWidget); + expect(find.text('Next'), findsOneWidget); + expect(find.text('Refresh'), findsOneWidget); + + await tester.ensureVisible(find.text('DDL')); + await tester.tap(find.text('DDL')); + expect(ddlOpened, isTrue); + + await tester.ensureVisible(find.text('Filter')); + await tester.tap(find.text('Filter')); + expect(filterToggled, isTrue); + + await tester.ensureVisible(find.text('Back')); + await tester.tap(find.text('Back'), warnIfMissed: false); + expect(prevClicked, isTrue); + + await tester.ensureVisible(find.text('Next')); + await tester.tap(find.text('Next'), warnIfMissed: false); + expect(nextClicked, isTrue); + + await tester.ensureVisible(find.text('Refresh')); + await tester.tap(find.text('Refresh'), warnIfMissed: false); + expect(refreshed, isTrue); + }); + + testWidgets('shows active filter status and disables nav when loading', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + theme: material.ThemeData.light(), + home: material.Scaffold( + body: ShadcnApp( + home: ExtensionTableToolbar( + title: 'View · analytics.summary', + paginationLabel: 'Loading data...', + tableIcon: material.Icons.view_list_rounded, + loading: true, + canGoPrevious: false, + canGoNext: false, + filterActive: true, + filterText: 'id > 100', + onToggleFilter: () {}, + onOpenDdl: () {}, + onGoPrevious: () {}, + onGoNext: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + expect(find.text('Filter (active)'), findsOneWidget); + expect(find.text('Loading data...'), findsOneWidget); + }); + }); +}