From ea974fe251af068cfa913c642c4111a8808283c0 Mon Sep 17 00:00:00 2001 From: "Dmitry Grand (dmgr)" Date: Wed, 23 Sep 2026 16:18:59 -0700 Subject: [PATCH 1/4] show filter dialog on / or ctrl+f --- dashboard/lib/views/presubmit_view.dart | 367 ++++++++++-------- dashboard/lib/widgets/filter_dialog.dart | 12 +- dashboard/test/utils/mocks.mocks.dart | 2 + .../views/presubmit_filter_view_test.dart | 105 +++++ 4 files changed, 318 insertions(+), 168 deletions(-) diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index 94a9680ee..0f0abbe2c 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -51,6 +51,8 @@ class _PreSubmitViewState extends State with WidgetsBindingObserver { PresubmitState? _presubmitState; late Map _currentQueryParams; + final FocusNode _viewFocusNode = FocusNode(); + bool _isFilterDialogOpen = false; @override void initState() { @@ -75,6 +77,7 @@ class _PreSubmitViewState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); _presubmitState?.removeListener(_onStateChanged); + _viewFocusNode.dispose(); super.dispose(); } @@ -130,6 +133,22 @@ class _PreSubmitViewState extends State }); } + Future _showFilterDialog() async { + if (_isFilterDialogOpen || !mounted) return; + _isFilterDialogOpen = true; + try { + await showDialog( + context: context, + builder: (context) => const FilterDialog(autofocusRegex: true), + ); + } finally { + _isFilterDialogOpen = false; + if (mounted && !_viewFocusNode.hasFocus) { + _viewFocusNode.requestFocus(); + } + } + } + Future _showErrorDialog(String message) async { return showDialog( context: context, @@ -156,184 +175,203 @@ class _PreSubmitViewState extends State final isDark = theme.brightness == Brightness.dark; final presubmitState = Provider.of(context); - return LayoutBuilder( - builder: (context, constraints) { - final isMobile = widget.isMobile || constraints.maxWidth < 600; - if (presubmitState.isMobile != isMobile) { - Future.microtask(() { - if (mounted) presubmitState.setMobile(isMobile); - }); - } - - return AnimatedBuilder( - animation: presubmitState, - builder: (context, _) { - final pr = presubmitState.pr; - final sha = presubmitState.sha; - final repo = presubmitState.repo; - - final guardResponse = presubmitState.guardResponse; - final isLoading = presubmitState.isLoading; - final selectedJob = presubmitState.selectedJob; - - var availableSummaries = presubmitState.availableSummaries; - - if (sha != null && - !availableSummaries.any((s) => s.headSha == sha)) { - availableSummaries = [ - PresubmitGuardSummary( - headSha: sha, - creationTime: 0, - guardStatus: GuardStatus.waitingForBackfill, - ), - ...availableSummaries, - ]; - } - - final shortSha = (sha != null && sha.length > 7) - ? sha.substring(0, 7) - : sha; - final title = guardResponse != null - ? (isMobile - ? '${guardResponse.prNum}' - : 'PR #${guardResponse.prNum} by ${guardResponse.author} ($shortSha)') - : (pr != null ? 'PR #$pr' : (sha != null ? '($shortSha)' : '')); - - var statusText = (pr != null ? 'Pending' : 'Loading...'); - if (guardResponse != null) { - statusText = guardResponse.guardStatus.value; - } else if (sha != null) { - final summary = presubmitState.availableSummaries.firstWhere( - (s) => s.headSha == sha, - orElse: () => const PresubmitGuardSummary( - headSha: '', - creationTime: 0, - guardStatus: GuardStatus.waitingForBackfill, - ), - ); - if (summary.headSha.isNotEmpty) { - statusText = summary.guardStatus.value; - } + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.slash): _showFilterDialog, + const SingleActivator(LogicalKeyboardKey.numpadDivide): + _showFilterDialog, + const SingleActivator(LogicalKeyboardKey.keyF, control: true): + _showFilterDialog, + const SingleActivator(LogicalKeyboardKey.keyF, meta: true): + _showFilterDialog, + }, + child: Focus( + focusNode: _viewFocusNode, + autofocus: true, + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = widget.isMobile || constraints.maxWidth < 600; + if (presubmitState.isMobile != isMobile) { + Future.microtask(() { + if (mounted) presubmitState.setMobile(isMobile); + }); } - final isLatestSha = - pr != null && - presubmitState.availableSummaries.isNotEmpty && - sha == presubmitState.availableSummaries.first.headSha; - - return Scaffold( - appBar: CocoonAppBar( - title: Row( - children: [ - Flexible( - child: SelectionArea( - child: Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + return AnimatedBuilder( + animation: presubmitState, + builder: (context, _) { + final pr = presubmitState.pr; + final sha = presubmitState.sha; + final repo = presubmitState.repo; + + final guardResponse = presubmitState.guardResponse; + final isLoading = presubmitState.isLoading; + final selectedJob = presubmitState.selectedJob; + + var availableSummaries = presubmitState.availableSummaries; + + if (sha != null && + !availableSummaries.any((s) => s.headSha == sha)) { + availableSummaries = [ + PresubmitGuardSummary( + headSha: sha, + creationTime: 0, + guardStatus: GuardStatus.waitingForBackfill, + ), + ...availableSummaries, + ]; + } + + final shortSha = (sha != null && sha.length > 7) + ? sha.substring(0, 7) + : sha; + final title = guardResponse != null + ? (isMobile + ? '${guardResponse.prNum}' + : 'PR #${guardResponse.prNum} by ${guardResponse.author} ($shortSha)') + : (pr != null + ? 'PR #$pr' + : (sha != null ? '($shortSha)' : '')); + + var statusText = (pr != null ? 'Pending' : 'Loading...'); + if (guardResponse != null) { + statusText = guardResponse.guardStatus.value; + } else if (sha != null) { + final summary = presubmitState.availableSummaries.firstWhere( + (s) => s.headSha == sha, + orElse: () => const PresubmitGuardSummary( + headSha: '', + creationTime: 0, + guardStatus: GuardStatus.waitingForBackfill, + ), + ); + if (summary.headSha.isNotEmpty) { + statusText = summary.guardStatus.value; + } + } + + final isLatestSha = + pr != null && + presubmitState.availableSummaries.isNotEmpty && + sha == presubmitState.availableSummaries.first.headSha; + + return Scaffold( + appBar: CocoonAppBar( + title: Row( + children: [ + Flexible( + child: SelectionArea( + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.ellipsis, ), - ), + if (!isMobile) ...[ + const SizedBox(width: 16), + pw.GuardStatus(status: statusText), + ], + ], ), - if (!isMobile) ...[ - const SizedBox(width: 16), - pw.GuardStatus(status: statusText), - ], - ], - ), - actions: [ - Center( - child: SizedBox( - width: isMobile ? 120 : 300, - child: ShaSelector( - availableShas: availableSummaries, - selectedSha: sha, - isMobile: isMobile, - onShaSelected: (newSha) { - presubmitState.update( - repo: repo, - pr: pr, - sha: newSha, - ); - }, + actions: [ + Center( + child: SizedBox( + width: isMobile ? 120 : 300, + child: ShaSelector( + availableShas: availableSummaries, + selectedSha: sha, + isMobile: isMobile, + onShaSelected: (newSha) { + presubmitState.update( + repo: repo, + pr: pr, + sha: newSha, + ); + }, + ), + ), ), - ), + const SizedBox(width: 8), + ], ), - const SizedBox(width: 8), - ], - ), - drawer: const DashboardNavigationDrawer(), - body: isLoading && guardResponse == null - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - const Divider(height: 1, thickness: 1), - Expanded( - child: SelectionArea( - child: isMobile - ? (selectedJob == null - ? (guardResponse != null - ? _buildJobsSidebarPane( + drawer: const DashboardNavigationDrawer(), + body: isLoading && guardResponse == null + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + const Divider(height: 1, thickness: 1), + Expanded( + child: SelectionArea( + child: isMobile + ? (selectedJob == null + ? (guardResponse != null + ? _buildJobsSidebarPane( + presubmitState: + presubmitState, + isMobile: true, + guardResponse: + guardResponse, + isLatestSha: isLatestSha, + isDark: isDark, + selectedJob: selectedJob, + ) + : const Center( + child: Text( + 'No stages available.', + ), + )) + : _JobDetailsViewerPane( + isMobile: true, + onError: _showErrorDialog, + )) + : Row( + children: [ + if (guardResponse != null) + SizedBox( + width: 350, + child: _buildJobsSidebarPane( presubmitState: presubmitState, - isMobile: true, + isMobile: false, guardResponse: guardResponse, isLatestSha: isLatestSha, isDark: isDark, selectedJob: selectedJob, - ) - : const Center( - child: Text( - 'No stages available.', - ), - )) - : _JobDetailsViewerPane( - isMobile: true, - onError: _showErrorDialog, - )) - : Row( - children: [ - if (guardResponse != null) - SizedBox( - width: 350, - child: _buildJobsSidebarPane( - presubmitState: presubmitState, - isMobile: false, - guardResponse: guardResponse, - isLatestSha: isLatestSha, - isDark: isDark, - selectedJob: selectedJob, - ), - ), - const VerticalDivider( - width: 1, - thickness: 1, - ), - Expanded( - child: - (selectedJob == null || - guardResponse == null) - ? const Center( - child: Text( - 'Select a job to view execution details.', - ), - ) - : _JobDetailsViewerPane( - isMobile: false, - onError: _showErrorDialog, ), + ), + const VerticalDivider( + width: 1, + thickness: 1, + ), + Expanded( + child: + (selectedJob == null || + guardResponse == null) + ? const Center( + child: Text( + 'Select a job to view execution details.', + ), + ) + : _JobDetailsViewerPane( + isMobile: false, + onError: _showErrorDialog, + ), + ), + ], ), - ], - ), - ), + ), + ), + ], ), - ], - ), + ); + }, ); }, - ); - }, + ), + ), ); } @@ -362,12 +400,7 @@ class _PreSubmitViewState extends State minimumSize: const Size(64, 18), foregroundColor: isDark ? Colors.white : Colors.black, ), - onPressed: () { - showDialog( - context: context, - builder: (context) => const FilterDialog(), - ); - }, + onPressed: _showFilterDialog, ), const Spacer(), if (isLatestSha) diff --git a/dashboard/lib/widgets/filter_dialog.dart b/dashboard/lib/widgets/filter_dialog.dart index 4e07f3f26..e1e6db08a 100644 --- a/dashboard/lib/widgets/filter_dialog.dart +++ b/dashboard/lib/widgets/filter_dialog.dart @@ -11,7 +11,9 @@ import 'task_box.dart'; /// A dialog that allows users to filter jobs in the Presubmit Dashboard. class FilterDialog extends StatefulWidget { - const FilterDialog({super.key}); + const FilterDialog({super.key, this.autofocusRegex = false}); + + final bool autofocusRegex; @override State createState() => _FilterDialogState(); @@ -33,6 +35,13 @@ class _FilterDialogState extends State { text: presubmitState.jobNameFilter, ); _regexFocusNode.addListener(_onRegexFocusChange); + if (widget.autofocusRegex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _regexFocusNode.requestFocus(); + } + }); + } } @override @@ -160,6 +169,7 @@ class _FilterDialogState extends State { TextField( controller: _regexController, focusNode: _regexFocusNode, + autofocus: widget.autofocusRegex, decoration: const InputDecoration( hintText: 'e.g. .*test.*', border: OutlineInputBorder(), diff --git a/dashboard/test/utils/mocks.mocks.dart b/dashboard/test/utils/mocks.mocks.dart index 49e1672de..f0972fa10 100644 --- a/dashboard/test/utils/mocks.mocks.dart +++ b/dashboard/test/utils/mocks.mocks.dart @@ -1319,6 +1319,7 @@ class MockFirebaseAuth extends _i1.Mock implements _i7.FirebaseAuth { String? phoneNumber, String? smsCode, bool? forceRecaptchaFlow, + bool? migrateCurrentUser, }) => (super.noSuchMethod( Invocation.method(#setSettings, [], { @@ -1328,6 +1329,7 @@ class MockFirebaseAuth extends _i1.Mock implements _i7.FirebaseAuth { #phoneNumber: phoneNumber, #smsCode: smsCode, #forceRecaptchaFlow: forceRecaptchaFlow, + #migrateCurrentUser: migrateCurrentUser, }), returnValue: _i8.Future.value(), returnValueForMissingStub: _i8.Future.value(), diff --git a/dashboard/test/views/presubmit_filter_view_test.dart b/dashboard/test/views/presubmit_filter_view_test.dart index f94244c80..e08a4a179 100644 --- a/dashboard/test/views/presubmit_filter_view_test.dart +++ b/dashboard/test/views/presubmit_filter_view_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_app_icons/flutter_app_icons_platform_interface.dart'; import 'package:flutter_dashboard/service/cocoon.dart'; import 'package:flutter_dashboard/state/build.dart'; @@ -230,4 +231,108 @@ void main() { expect(find.text('mac test'), findsOneWidget); expect(find.byIcon(Icons.filter_alt), findsOneWidget); }); + + testWidgets( + 'Pressing / opens FilterDialog and focuses Job Name (Regex) field', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + jobs: {'linux test': TaskStatus.succeeded}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'abc'), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.byIcon(Icons.filter_alt_outlined).evaluate().isNotEmpty) { + break; + } + } + }); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.slash); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsOneWidget); + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + }, + ); + + testWidgets( + 'Pressing Ctrl+F opens FilterDialog and focuses Job Name (Regex) field', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.succeeded, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'stage1', + createdAt: 0, + jobs: {'linux test': TaskStatus.succeeded}, + ), + ], + ); + + when( + mockCocoonService.fetchPresubmitGuard(repo: 'flutter', sha: 'abc'), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({'repo': 'flutter', 'sha': 'abc'}), + ); + for (var i = 0; i < 50; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.byIcon(Icons.filter_alt_outlined).evaluate().isNotEmpty) { + break; + } + } + }); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(find.byType(FilterDialog), findsOneWidget); + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + }, + ); } From dfcb0f9de07f90da076e2f32c0de0768ace3cc75 Mon Sep 17 00:00:00 2001 From: "Dmitry Grand (dmgr)" Date: Wed, 23 Sep 2026 16:21:19 -0700 Subject: [PATCH 2/4] selectable test in error dialog --- dashboard/lib/views/presubmit_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/lib/views/presubmit_view.dart b/dashboard/lib/views/presubmit_view.dart index 0f0abbe2c..216c80d85 100644 --- a/dashboard/lib/views/presubmit_view.dart +++ b/dashboard/lib/views/presubmit_view.dart @@ -155,7 +155,7 @@ class _PreSubmitViewState extends State builder: (BuildContext context) { return AlertDialog( title: const Text('Error'), - content: Text(message), + content: SelectionArea(child: Text(message)), actions: [ TextButton( child: const Text('OK'), From 134e9e8d1bbe09660e0aae0562e65409e80f959f Mon Sep 17 00:00:00 2001 From: "Dmitry Grand (dmgr)" Date: Wed, 23 Sep 2026 16:23:59 -0700 Subject: [PATCH 3/4] test --- dashboard/test/views/presubmit_view_test.dart | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/dashboard/test/views/presubmit_view_test.dart b/dashboard/test/views/presubmit_view_test.dart index 85a2cfdd2..2714bb1fb 100644 --- a/dashboard/test/views/presubmit_view_test.dart +++ b/dashboard/test/views/presubmit_view_test.dart @@ -1125,6 +1125,81 @@ void main() { expect(tester.widget(rerunAllButton).onPressed, isNotNull); expect(tester.widget(rerunButton).onPressed, isNotNull); }); + + testWidgets('error dialog message is wrapped in SelectionArea', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(2000, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + when(mockAuthService.isAuthenticated).thenReturn(true); + + const guardResponse = PresubmitGuardResponse( + prNum: 123, + author: 'dash', + guardStatus: GuardStatus.failed, + checkRunId: 456, + stages: [ + PresubmitGuardStage( + name: 'Engine', + createdAt: 0, + jobs: {'linux_bot': TaskStatus.failed}, + ), + ], + ); + + when( + mockCocoonService.rerunAllFailedJobs( + idToken: anyNamed('idToken'), + repo: anyNamed('repo'), + pr: anyNamed('pr'), + ), + ).thenAnswer( + (_) async => const CocoonResponse.error( + 'Sample error message', + statusCode: 500, + ), + ); + + when( + mockCocoonService.fetchPresubmitGuard( + repo: 'flutter', + sha: 'decaf_3_real_sha', + ), + ).thenAnswer((_) async => const CocoonResponse.data(guardResponse)); + + await tester.runAsync(() async { + await tester.pumpWidget( + createPreSubmitView({ + 'repo': 'flutter', + 'pr': '123', + 'sha': 'decaf_3_real_sha', + }), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + if (find.textContaining('linux_bot').evaluate().isNotEmpty) break; + } + }); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Re-run failed')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget is SelectionArea && + widget.child is Text && + (widget.child as Text).data == 'Sample error message', + ), + findsOneWidget, + ); + }); }); group('PreSubmitView Sorting', () { From 26804606a34651779e5c545bd715f6029ec0c5cc Mon Sep 17 00:00:00 2001 From: "Dmitry Grand (dmgr)" Date: Wed, 23 Sep 2026 16:29:35 -0700 Subject: [PATCH 4/4] fix ai review --- dashboard/lib/widgets/filter_dialog.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dashboard/lib/widgets/filter_dialog.dart b/dashboard/lib/widgets/filter_dialog.dart index e1e6db08a..cc691efa2 100644 --- a/dashboard/lib/widgets/filter_dialog.dart +++ b/dashboard/lib/widgets/filter_dialog.dart @@ -35,13 +35,6 @@ class _FilterDialogState extends State { text: presubmitState.jobNameFilter, ); _regexFocusNode.addListener(_onRegexFocusChange); - if (widget.autofocusRegex) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _regexFocusNode.requestFocus(); - } - }); - } } @override