From 29cad57ada516b0ff29f183e3f54cdd808887610 Mon Sep 17 00:00:00 2001 From: "Dmitry Grand (dmgr)" Date: Fri, 25 Sep 2026 17:42:23 -0700 Subject: [PATCH] close filter dialog by pressing `esc` or `enter` --- dashboard/lib/views/presubmit_view.dart | 2 +- dashboard/lib/widgets/filter_dialog.dart | 158 ++++++++++-------- .../test/widgets/filter_dialog_test.dart | 47 ++++++ 3 files changed, 135 insertions(+), 72 deletions(-) diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index 216c80d85..fef6a1afc 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -139,7 +139,7 @@ class _PreSubmitViewState extends State try { await showDialog( context: context, - builder: (context) => const FilterDialog(autofocusRegex: true), + builder: (context) => const FilterDialog(), ); } finally { _isFilterDialogOpen = false; diff --git a/dashboard/lib/widgets/filter_dialog.dart b/dashboard/lib/widgets/filter_dialog.dart index cc691efa2..98345a521 100644 --- a/dashboard/lib/widgets/filter_dialog.dart +++ b/dashboard/lib/widgets/filter_dialog.dart @@ -4,6 +4,7 @@ import 'package:cocoon_common/task_status.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../state/presubmit.dart'; @@ -11,9 +12,7 @@ import 'task_box.dart'; /// A dialog that allows users to filter jobs in the Presubmit Dashboard. class FilterDialog extends StatefulWidget { - const FilterDialog({super.key, this.autofocusRegex = false}); - - final bool autofocusRegex; + const FilterDialog({super.key}); @override State createState() => _FilterDialogState(); @@ -45,6 +44,8 @@ class _FilterDialogState extends State { super.dispose(); } + bool _isClosing = false; + void _onRegexFocusChange() { if (!_regexFocusNode.hasFocus) { _applyFilters(); @@ -60,6 +61,13 @@ class _FilterDialogState extends State { ); } + void _closeDialog() { + if (_isClosing || !mounted) return; + _isClosing = true; + _applyFilters(); + Navigator.of(context).pop(); + } + void _onRegexChanged(String value) { setState(() {}); _applyFilters(); @@ -118,79 +126,87 @@ class _FilterDialogState extends State { ) ?? 0; - return AlertDialog( - title: const Text('Filter jobs'), - content: SizedBox( - width: 500, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Status', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: TaskStatus.values.map((status) { - final isSelected = _selectedStatuses.contains(status); - return FilterChip( - label: Text(status.value), - selected: isSelected, - onSelected: (_) => _toggleStatus(status), - avatar: _getStatusIcon(status), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text('Platform', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: availablePlatforms.map((platform) { - return FilterChip( - label: Text(platform), - selected: _selectedPlatforms.contains(platform), - onSelected: (_) => _togglePlatform(platform), - ); - }).toList(), - ), - const SizedBox(height: 16), - Text('Job Name (Regex)', style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - TextField( - controller: _regexController, - focusNode: _regexFocusNode, - autofocus: widget.autofocusRegex, - decoration: const InputDecoration( - hintText: 'e.g. .*test.*', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): _closeDialog, + const SingleActivator(LogicalKeyboardKey.numpadEnter): _closeDialog, + const SingleActivator(LogicalKeyboardKey.escape): _closeDialog, + }, + child: FocusScope( + autofocus: true, + child: AlertDialog( + title: const Text('Filter jobs'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Status', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: TaskStatus.values.map((status) { + final isSelected = _selectedStatuses.contains(status); + return FilterChip( + label: Text(status.value), + selected: isSelected, + onSelected: (_) => _toggleStatus(status), + avatar: _getStatusIcon(status), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text('Platform', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: availablePlatforms.map((platform) { + return FilterChip( + label: Text(platform), + selected: _selectedPlatforms.contains(platform), + onSelected: (_) => _togglePlatform(platform), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text('Job Name (Regex)', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _regexController, + focusNode: _regexFocusNode, + autofocus: true, + decoration: const InputDecoration( + hintText: 'e.g. .*test.*', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onChanged: _onRegexChanged, + onEditingComplete: _applyFilters, + onSubmitted: (_) => _closeDialog(), ), - ), - onChanged: _onRegexChanged, - onEditingComplete: _applyFilters, + ], ), - ], + ), ), + actions: [ + TextButton( + onPressed: _clearAll, + child: const Text('Clear all filters'), + ), + ElevatedButton( + onPressed: _closeDialog, + child: Text('Show $filteredCount jobs'), + ), + ], ), ), - actions: [ - TextButton( - onPressed: _clearAll, - child: const Text('Clear all filters'), - ), - ElevatedButton( - onPressed: () { - _applyFilters(); - Navigator.of(context).pop(); - }, - child: Text('Show $filteredCount jobs'), - ), - ], ); } diff --git a/dashboard/test/widgets/filter_dialog_test.dart b/dashboard/test/widgets/filter_dialog_test.dart index 9e5a249b8..0d58b4250 100644 --- a/dashboard/test/widgets/filter_dialog_test.dart +++ b/dashboard/test/widgets/filter_dialog_test.dart @@ -6,6 +6,7 @@ import 'package:cocoon_common/guard_status.dart'; import 'package:cocoon_common/rpc_model.dart'; import 'package:cocoon_common/task_status.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dashboard/service/cocoon.dart'; import 'package:flutter_dashboard/state/presubmit.dart'; import 'package:flutter_dashboard/widgets/filter_dialog.dart'; @@ -69,6 +70,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); } testWidgets('FilterDialog shows all statuses and platforms', ( @@ -189,4 +191,49 @@ void main() { isEmpty, ); }); + + testWidgets('Pressing Escape closes FilterDialog', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + expect(find.byType(FilterDialog), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsNothing); + }); + + testWidgets('Pressing Enter closes FilterDialog and applies filters', ( + WidgetTester tester, + ) async { + await pumpDialog(tester); + expect(find.byType(FilterDialog), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'linux'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsNothing); + expect(presubmitState.jobNameFilter, 'linux'); + }); + + testWidgets( + 'Pressing Enter closes FilterDialog when focus is not on TextField', + (WidgetTester tester) async { + await pumpDialog(tester); + expect(find.byType(FilterDialog), findsOneWidget); + + await tester.tap(find.text('linux')); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsNothing); + expect(presubmitState.selectedPlatforms, {'mac'}); + }, + ); }