Skip to content

Commit 1bb9a18

Browse files
authored
Initial read-only implementation of the try-results page (#183)
* Adds services to interact with the firestore test result data. * Adds a route for cl/\<cl number>/\<patchset number>. * Supports basic features for viewing CL results. * Enabled lints to keep imports tidy. Known issues in try results: * Doesn't handle status "skipped" (currently displays it as failures). * Source links don't work. * Free-text filter doesn't work. * No commenting/approval feature. * Doesn't handle non-existing CLs/patchsets very well.
1 parent 6074272 commit 1bb9a18

21 files changed

+1132
-429
lines changed

current_results_ui/analysis_options.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ analyzer:
77

88
linter:
99
rules:
10-
# Disabled - currently one violation.
10+
# Disabled - TODO(athom): enable after results_feed port is finished.
1111
avoid_print: false
12+
directives_ordering: true
13+
prefer_relative_imports: true
1214

1315
# Additional information about this file can be found at
1416
# https://dart.dev/guides/language/analysis-options

current_results_ui/lib/filter.dart

Lines changed: 59 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -37,83 +37,71 @@ class _FilterUIState extends State<FilterUI> {
3737
final controller = TextEditingController();
3838

3939
@override
40-
void dispose() {
41-
controller.dispose();
42-
super.dispose();
43-
}
40+
Widget build(BuildContext context) {
41+
final results = context.watch<QueryResultsBase>();
42+
final filter = results.filter;
4443

45-
void _updateFilter(Iterable<String> newTerms) {
46-
final uri = GoRouter.of(context).routeInformationProvider.value.uri;
47-
final newUri = uri.replace(
48-
queryParameters: {...uri.queryParameters, 'filter': newTerms.join(',')},
49-
);
50-
GoRouter.of(context).go(newUri.toString());
51-
}
44+
void updateFilter(Iterable<String> newTerms) {
45+
final uri = GoRouter.of(context).routeInformationProvider.value.uri;
46+
final newUri = uri.replace(
47+
queryParameters: {...uri.queryParameters, 'filter': newTerms.join(',')},
48+
);
49+
GoRouter.of(context).go(newUri.toString());
50+
}
5251

53-
@override
54-
Widget build(BuildContext context) {
55-
return Consumer<QueryResultsBase>(
56-
builder: (context, results, child) {
57-
final filter = results.filter;
58-
return Column(
59-
crossAxisAlignment: CrossAxisAlignment.start,
60-
children: [
61-
ConstrainedBox(
62-
constraints: const BoxConstraints(maxHeight: 100.0),
63-
child: Scrollbar(
64-
child: SingleChildScrollView(
65-
child: Container(
66-
padding: const EdgeInsets.only(top: 16.0),
67-
alignment: Alignment.topLeft,
68-
child: Wrap(
69-
spacing: 8.0,
70-
runSpacing: 8.0,
71-
alignment: WrapAlignment.start,
72-
children: [
73-
for (final term in filter.terms)
74-
InputChip(
75-
label: Text(term),
76-
onDeleted: () {
77-
_updateFilter(
78-
filter.terms.where((t) => t != term),
79-
);
80-
},
81-
onPressed: () {
82-
controller.text = term;
83-
},
84-
),
85-
],
86-
),
87-
),
52+
return Column(
53+
crossAxisAlignment: CrossAxisAlignment.start,
54+
children: [
55+
ConstrainedBox(
56+
constraints: const BoxConstraints(maxHeight: 100.0),
57+
child: Scrollbar(
58+
child: SingleChildScrollView(
59+
child: Container(
60+
padding: const EdgeInsets.only(top: 16.0),
61+
alignment: Alignment.topLeft,
62+
child: Wrap(
63+
spacing: 8.0,
64+
runSpacing: 8.0,
65+
alignment: WrapAlignment.start,
66+
children: [
67+
for (final term in filter.terms)
68+
InputChip(
69+
label: Text(term),
70+
onDeleted: () {
71+
updateFilter(filter.terms.where((t) => t != term));
72+
},
73+
onPressed: () {
74+
controller.text = term;
75+
},
76+
),
77+
],
8878
),
8979
),
9080
),
91-
SizedBox(
92-
width: 300.0,
93-
child: TextField(
94-
controller: controller,
95-
decoration: const InputDecoration(
96-
hintText: 'Test, configuration or experiment prefix',
97-
),
98-
onSubmitted: (value) {
99-
if (value.trim().isEmpty) return;
100-
final newTerms = value.split(',').map((s) => s.trim());
101-
bool isNotReplacedByNewTerm(String term) => !newTerms.any(
102-
(newTerm) =>
103-
term.startsWith(newTerm) || newTerm.startsWith(term),
104-
);
105-
controller.text = '';
106-
_updateFilter(
107-
filter.terms
108-
.where(isNotReplacedByNewTerm)
109-
.followedBy(newTerms),
110-
);
111-
},
112-
),
81+
),
82+
),
83+
SizedBox(
84+
width: 300.0,
85+
child: TextField(
86+
controller: controller,
87+
decoration: const InputDecoration(
88+
hintText: 'Test, configuration or experiment prefix',
11389
),
114-
],
115-
);
116-
},
90+
onSubmitted: (value) {
91+
if (value.trim().isEmpty) return;
92+
final newTerms = value.split(',').map((s) => s.trim());
93+
bool isNotReplacedByNewTerm(String term) => !newTerms.any(
94+
(newTerm) =>
95+
term.startsWith(newTerm) || newTerm.startsWith(term),
96+
);
97+
controller.text = '';
98+
updateFilter(
99+
filter.terms.where(isNotReplacedByNewTerm).followedBy(newTerms),
100+
);
101+
},
102+
),
103+
),
104+
],
117105
);
118106
}
119107
}

current_results_ui/lib/main.dart

Lines changed: 4 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,13 @@
44

55
import 'package:firebase_core/firebase_core.dart';
66
import 'package:flutter/material.dart';
7-
import 'package:flutter/services.dart';
8-
import 'package:flutter_current_results/firebase_options.dart';
9-
import 'package:flutter_current_results/src/routing.dart';
10-
import 'package:flutter_current_results/src/widgets/app_bar_actions.dart';
11-
import 'package:go_router/go_router.dart';
127
import 'package:provider/provider.dart';
13-
import 'package:url_launcher/url_launcher.dart' as url_launcher;
148

15-
import 'filter.dart';
16-
import 'query.dart';
17-
import 'results.dart';
9+
import 'firebase_options.dart';
1810
import 'src/auth_service.dart';
1911
import 'src/platform_specific/url_strategy_stub.dart'
2012
if (dart.library.js_interop) 'src/platform_specific/url_strategy_web.dart';
13+
import 'src/routing.dart';
2114

2215
Future<void> main() async {
2316
WidgetsFlutterBinding.ensureInitialized();
@@ -66,7 +59,8 @@ class CurrentResultsApp extends StatelessWidget {
6659

6760
@override
6861
Widget build(BuildContext context) {
69-
return AppProviders(
62+
return ChangeNotifierProvider<AuthService>(
63+
create: (_) => AuthService(),
7064
child: MaterialApp.router(
7165
title: 'Current Results',
7266
theme: ThemeData(
@@ -78,203 +72,3 @@ class CurrentResultsApp extends StatelessWidget {
7872
);
7973
}
8074
}
81-
82-
class AppProviders extends StatelessWidget {
83-
final Widget child;
84-
const AppProviders({super.key, required this.child});
85-
86-
@override
87-
Widget build(BuildContext context) {
88-
return MultiProvider(
89-
providers: [
90-
ChangeNotifierProvider<AuthService>(create: (_) => AuthService()),
91-
ChangeNotifierProvider<QueryResultsBase>(
92-
create: (_) => QueryResults(Filter('')),
93-
),
94-
],
95-
child: child,
96-
);
97-
}
98-
}
99-
100-
class CurrentResultsScreen extends StatefulWidget {
101-
final Filter filter;
102-
final int initialTabIndex;
103-
const CurrentResultsScreen({
104-
super.key,
105-
required this.filter,
106-
this.initialTabIndex = 0,
107-
});
108-
109-
@override
110-
State<CurrentResultsScreen> createState() => _CurrentResultsScreenState();
111-
}
112-
113-
class _CurrentResultsScreenState extends State<CurrentResultsScreen>
114-
with TickerProviderStateMixin {
115-
late final TabController _tabController;
116-
117-
@override
118-
void initState() {
119-
super.initState();
120-
Provider.of<QueryResultsBase>(context, listen: false).filter =
121-
widget.filter;
122-
_tabController = TabController(
123-
initialIndex: widget.initialTabIndex,
124-
length: 3,
125-
vsync: this,
126-
);
127-
}
128-
129-
@override
130-
void didUpdateWidget(CurrentResultsScreen oldWidget) {
131-
super.didUpdateWidget(oldWidget);
132-
if (widget.filter != oldWidget.filter) {
133-
Provider.of<QueryResultsBase>(context, listen: false).filter =
134-
widget.filter;
135-
}
136-
_tabController.index = widget.initialTabIndex;
137-
}
138-
139-
@override
140-
Widget build(BuildContext context) {
141-
return ChangeNotifierProvider<TabController>.value(
142-
value: _tabController,
143-
child: CurrentResultsScaffold(tabController: _tabController),
144-
);
145-
}
146-
}
147-
148-
class CurrentResultsScaffold extends StatelessWidget {
149-
final TabController tabController;
150-
const CurrentResultsScaffold({super.key, required this.tabController});
151-
152-
@override
153-
Widget build(BuildContext context) {
154-
return Align(
155-
alignment: Alignment.topLeft,
156-
child: Scaffold(
157-
appBar: AppBar(
158-
centerTitle: true,
159-
leading: const Center(child: FetchingProgress()),
160-
title: const Text(
161-
'Current Results',
162-
style: TextStyle(fontSize: 24.0),
163-
),
164-
actions: buildAppBarActions(context),
165-
bottom: TabBar(
166-
controller: tabController,
167-
tabs: const [
168-
Tab(text: 'FAILURES'),
169-
Tab(text: 'FLAKES'),
170-
Tab(text: 'ALL'),
171-
],
172-
onTap: (int tab) {
173-
final uri = GoRouter.of(
174-
context,
175-
).routeInformationProvider.value.uri;
176-
final newUri = uri.replace(
177-
queryParameters: {
178-
for (final entry in uri.queryParameters.entries)
179-
if (entry.key != 'showAll' && entry.key != 'flaky')
180-
entry.key: entry.value,
181-
if (tab == 2) 'showAll': 'true',
182-
if (tab == 1) 'flaky': 'true',
183-
},
184-
);
185-
GoRouter.of(context).go(newUri.toString());
186-
},
187-
),
188-
),
189-
persistentFooterButtons: const [
190-
ResultsSummary(),
191-
TestSummary(),
192-
ApiPortalLink(),
193-
JsonLink(),
194-
TextPopup(),
195-
],
196-
body: const SelectionArea(
197-
child: Column(
198-
children: [
199-
Padding(
200-
padding: EdgeInsets.symmetric(horizontal: 24.0),
201-
child: FilterUI(),
202-
),
203-
Divider(color: Colors.black12, height: 20),
204-
Expanded(child: ResultsPanel()),
205-
],
206-
),
207-
),
208-
),
209-
);
210-
}
211-
}
212-
213-
class ApiPortalLink extends StatelessWidget {
214-
const ApiPortalLink({super.key});
215-
216-
@override
217-
Widget build(BuildContext context) {
218-
return TextButton(
219-
child: const Text('API portal'),
220-
onPressed: () => url_launcher.launchUrl(
221-
Uri.https(
222-
'endpointsportal.dart-ci.cloud.goog',
223-
'/docs/current-results-qvyo5rktwa-uc.a.run.app/g'
224-
'/routes/v1/results/get',
225-
),
226-
),
227-
);
228-
}
229-
}
230-
231-
class JsonLink extends StatelessWidget {
232-
const JsonLink({super.key});
233-
234-
@override
235-
Widget build(BuildContext context) {
236-
return Consumer<QueryResultsBase>(
237-
builder: (context, results, child) {
238-
return TextButton(
239-
child: const Text('JSON'),
240-
onPressed: () => url_launcher.launchUrl(
241-
Uri.https(apiHost, 'v1/results', {
242-
'filter': results.filter.terms.join(','),
243-
'pageSize': '4000',
244-
}),
245-
),
246-
);
247-
},
248-
);
249-
}
250-
}
251-
252-
class TextPopup extends StatelessWidget {
253-
const TextPopup({super.key});
254-
255-
@override
256-
Widget build(BuildContext context) {
257-
return Consumer<QueryResultsBase>(
258-
builder: (context, QueryResultsBase results, child) {
259-
return Tooltip(
260-
message: 'Results query as text',
261-
waitDuration: const Duration(milliseconds: 500),
262-
child: TextButton(
263-
child: const Text('Copy to clipboard as text'),
264-
onPressed: () {
265-
final text = [resultTextHeader]
266-
.followedBy(
267-
results.names
268-
.expand((name) => results.grouped[name]!.values)
269-
.expand((list) => list)
270-
.map(resultAsCommaSeparated),
271-
)
272-
.join('\n');
273-
Clipboard.setData(ClipboardData(text: text));
274-
},
275-
),
276-
);
277-
},
278-
);
279-
}
280-
}

0 commit comments

Comments
 (0)