diff --git a/dev/conductor/ui/lib/widgets/conductor_status.dart b/dev/conductor/ui/lib/widgets/conductor_status.dart index c5d22004a6b..acbeb5b4228 100644 --- a/dev/conductor/ui/lib/widgets/conductor_status.dart +++ b/dev/conductor/ui/lib/widgets/conductor_status.dart @@ -18,7 +18,7 @@ class ConductorStatus extends StatefulWidget { final String stateFilePath; @override - ConductorStatusState createState() => ConductorStatusState(); + State createState() => ConductorStatusState(); static final List headerElements = [ 'Conductor Version', @@ -194,7 +194,7 @@ class CherrypickTable extends StatefulWidget { final Map currentStatus; @override - CherrypickTableState createState() => CherrypickTableState(); + State createState() => CherrypickTableState(); } class CherrypickTableState extends State { @@ -242,7 +242,7 @@ class RepoInfoExpansion extends StatefulWidget { final Map currentStatus; @override - RepoInfoExpansionState createState() => RepoInfoExpansionState(); + State createState() => RepoInfoExpansionState(); } class RepoInfoExpansionState extends State { diff --git a/dev/conductor/ui/lib/widgets/create_release_substeps.dart b/dev/conductor/ui/lib/widgets/create_release_substeps.dart new file mode 100644 index 00000000000..7ffa4defb32 --- /dev/null +++ b/dev/conductor/ui/lib/widgets/create_release_substeps.dart @@ -0,0 +1,184 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Displays all substeps related to the 1st step. +/// +/// Uses input fields and dropdowns to capture all the parameters of the conductor start command. +class CreateReleaseSubsteps extends StatefulWidget { + const CreateReleaseSubsteps({ + Key? key, + required this.nextStep, + }) : super(key: key); + + final VoidCallback nextStep; + + @override + State createState() => CreateReleaseSubstepsState(); + + static const List substepTitles = [ + 'Candidate Branch', + 'Release Channel', + 'Framework Mirror', + 'Engine Mirror', + 'Engine Cherrypicks (if necessary)', + 'Framework Cherrypicks (if necessary)', + 'Dart Revision (if necessary)', + 'Increment', + ]; +} + +class CreateReleaseSubstepsState extends State { + // Initialize a public state so it could be accessed in the test file. + @visibleForTesting + late Map releaseData = {}; + + /// Updates the corresponding [field] in [releaseData] with [data]. + void setReleaseData(String field, String data) { + setState(() { + releaseData = { + ...releaseData, + field: data, + }; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InputAsSubstep( + index: 0, + setReleaseData: setReleaseData, + hintText: 'The candidate branch the release will be based on.', + ), + CheckboxListTileDropdown( + index: 1, + releaseData: releaseData, + setReleaseData: setReleaseData, + options: const ['dev', 'beta', 'stable'], + ), + InputAsSubstep( + index: 2, + setReleaseData: setReleaseData, + hintText: "Git remote of the Conductor user's Framework repository mirror.", + ), + InputAsSubstep( + index: 3, + setReleaseData: setReleaseData, + hintText: "Git remote of the Conductor user's Engine repository mirror.", + ), + InputAsSubstep( + index: 4, + setReleaseData: setReleaseData, + hintText: 'Engine cherrypick hashes to be applied. Multiple hashes delimited by a comma, no spaces.', + ), + InputAsSubstep( + index: 5, + setReleaseData: setReleaseData, + hintText: 'Framework cherrypick hashes to be applied. Multiple hashes delimited by a comma, no spaces.', + ), + InputAsSubstep( + index: 6, + setReleaseData: setReleaseData, + hintText: 'New Dart revision to cherrypick.', + ), + CheckboxListTileDropdown( + index: 7, + releaseData: releaseData, + setReleaseData: setReleaseData, + options: const ['y', 'z', 'm', 'n'], + ), + const SizedBox(height: 20.0), + Center( + // TODO(Yugue): Add regex validation for each parameter input + // before Continue button is enabled, https://github.com/flutter/flutter/issues/91925. + child: ElevatedButton( + key: const Key('step1continue'), + onPressed: () { + widget.nextStep(); + }, + child: const Text('Continue'), + ), + ), + ], + ); + } +} + +typedef SetReleaseData = void Function(String name, String data); + +/// Captures the input values and updates the corresponding field in [releaseData]. +class InputAsSubstep extends StatelessWidget { + const InputAsSubstep({ + Key? key, + required this.index, + required this.setReleaseData, + this.hintText, + }) : super(key: key); + + final int index; + final SetReleaseData setReleaseData; + final String? hintText; + + @override + Widget build(BuildContext context) { + return TextFormField( + key: Key(CreateReleaseSubsteps.substepTitles[index]), + decoration: InputDecoration( + labelText: CreateReleaseSubsteps.substepTitles[index], + hintText: hintText, + ), + onChanged: (String data) { + setReleaseData(CreateReleaseSubsteps.substepTitles[index], data); + }, + ); + } +} + +/// Captures the chosen option and updates the corresponding field in [releaseData]. +class CheckboxListTileDropdown extends StatelessWidget { + const CheckboxListTileDropdown({ + Key? key, + required this.index, + required this.releaseData, + required this.setReleaseData, + required this.options, + }) : super(key: key); + + final int index; + final Map releaseData; + final SetReleaseData setReleaseData; + final List options; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + CreateReleaseSubsteps.substepTitles[index], + style: Theme.of(context).textTheme.subtitle1!.copyWith(color: Colors.grey[700]), + ), + const SizedBox(width: 20.0), + DropdownButton( + hint: const Text('-'), // Dropdown initially displays the hint when no option is selected. + key: Key(CreateReleaseSubsteps.substepTitles[index]), + value: releaseData[CreateReleaseSubsteps.substepTitles[index]], + icon: const Icon(Icons.arrow_downward), + items: options.map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + setReleaseData(CreateReleaseSubsteps.substepTitles[index], newValue!); + }, + ), + ], + ); + } +} diff --git a/dev/conductor/ui/lib/widgets/progression.dart b/dev/conductor/ui/lib/widgets/progression.dart index 91e0fb05f3b..f699220e66f 100644 --- a/dev/conductor/ui/lib/widgets/progression.dart +++ b/dev/conductor/ui/lib/widgets/progression.dart @@ -6,6 +6,7 @@ import 'package:conductor_core/proto.dart' as pb; import 'package:flutter/material.dart'; import 'conductor_status.dart'; +import 'create_release_substeps.dart'; import 'substeps.dart'; /// Displays the progression and each step of the release from the conductor. @@ -23,7 +24,7 @@ class MainProgression extends StatefulWidget { final String stateFilePath; @override - MainProgressionState createState() => MainProgressionState(); + State createState() => MainProgressionState(); static const List _stepTitles = [ 'Initialize a New Flutter Release', @@ -85,7 +86,7 @@ class MainProgressionState extends State { title: Text(MainProgression._stepTitles[0]), content: Column( children: [ - ConductorSubsteps(nextStep: nextStep), + CreateReleaseSubsteps(nextStep: nextStep), ], ), isActive: true, diff --git a/dev/conductor/ui/lib/widgets/substeps.dart b/dev/conductor/ui/lib/widgets/substeps.dart index ee49f6f0ccf..f451d031672 100644 --- a/dev/conductor/ui/lib/widgets/substeps.dart +++ b/dev/conductor/ui/lib/widgets/substeps.dart @@ -16,7 +16,7 @@ class ConductorSubsteps extends StatefulWidget { final VoidCallback nextStep; @override - ConductorSubstepsState createState() => ConductorSubstepsState(); + State createState() => ConductorSubstepsState(); static const List _substepTitles = [ 'Substep 1', diff --git a/dev/conductor/ui/test/widgets/create_release_substeps_test.dart b/dev/conductor/ui/test/widgets/create_release_substeps_test.dart new file mode 100644 index 00000000000..39fba1a5f5f --- /dev/null +++ b/dev/conductor/ui/test/widgets/create_release_substeps_test.dart @@ -0,0 +1,78 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:conductor_ui/widgets/create_release_substeps.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Widget should save all parameters correctly', (WidgetTester tester) async { + const String candidateBranch = 'flutter-1.2-candidate.3'; + const String releaseChannel = 'dev'; + const String frameworkMirror = 'git@github.com:test/flutter.git'; + const String engineMirror = 'git@github.com:test/engine.git'; + const String engineCherrypick = 'a5a25cd702b062c24b2c67b8d30b5cb33e0ef6f0,94d06a2e1d01a3b0c693b94d70c5e1df9d78d249'; + const String frameworkCherrypick = '768cd702b691584b2c67b8d30b5cb33e0ef6f0'; + const String dartRevision = 'fe9708ab688dcda9923f584ba370a66fcbc3811f'; + const String increment = 'y'; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: ListView( + children: [ + CreateReleaseSubsteps( + nextStep: () {}, + ), + ], + ), + ), + ); + }, + ), + ); + + await tester.enterText(find.byKey(const Key('Candidate Branch')), candidateBranch); + + final StatefulElement createReleaseSubsteps = tester.element(find.byType(CreateReleaseSubsteps)); + final CreateReleaseSubstepsState createReleaseSubstepsState = + createReleaseSubsteps.state as CreateReleaseSubstepsState; + + /// Tests the Release Channel dropdown menu. + await tester.tap(find.byKey(const Key('Release Channel'))); + await tester.pumpAndSettle(); // finish the menu animation + expect(createReleaseSubstepsState.releaseData['Release Channel'], equals(null)); + await tester.tap(find.text(releaseChannel).last); + await tester.pumpAndSettle(); // finish the menu animation + + await tester.enterText(find.byKey(const Key('Framework Mirror')), frameworkMirror); + await tester.enterText(find.byKey(const Key('Engine Mirror')), engineMirror); + await tester.enterText(find.byKey(const Key('Engine Cherrypicks (if necessary)')), engineCherrypick); + await tester.enterText(find.byKey(const Key('Framework Cherrypicks (if necessary)')), frameworkCherrypick); + await tester.enterText(find.byKey(const Key('Dart Revision (if necessary)')), dartRevision); + + /// Tests the Increment dropdown menu. + await tester.tap(find.byKey(const Key('Increment'))); + await tester.pumpAndSettle(); // finish the menu animation + expect(createReleaseSubstepsState.releaseData['Increment'], equals(null)); + await tester.tap(find.text(increment).last); + await tester.pumpAndSettle(); // finish the menu animation + + expect( + createReleaseSubstepsState.releaseData, + equals({ + 'Candidate Branch': candidateBranch, + 'Release Channel': releaseChannel, + 'Framework Mirror': frameworkMirror, + 'Engine Mirror': engineMirror, + 'Engine Cherrypicks (if necessary)': engineCherrypick, + 'Framework Cherrypicks (if necessary)': frameworkCherrypick, + 'Dart Revision (if necessary)': dartRevision, + 'Increment': increment, + })); + }); +} diff --git a/dev/conductor/ui/test/widgets/stepper_test.dart b/dev/conductor/ui/test/widgets/stepper_test.dart index 60d8d13229f..9bbaf68a581 100644 --- a/dev/conductor/ui/test/widgets/stepper_test.dart +++ b/dev/conductor/ui/test/widgets/stepper_test.dart @@ -7,50 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets( - 'All substeps of the current step must be checked before able to continue to the next step', - (WidgetTester tester) async { - await tester.pumpWidget( - StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MaterialApp( - home: Material( - child: Column( - children: const [ - MainProgression( - stateFilePath: './testPath', - ), - ], - ), - ), - ); - }, - ), - ); - - expect(find.byType(Stepper), findsOneWidget); - expect(find.text('Initialize a New Flutter Release'), findsOneWidget); - expect(find.text('Continue'), findsNWidgets(0)); - - await tester.tap(find.text('Substep 1').first); - await tester.tap(find.text('Substep 2').first); - await tester.pumpAndSettle(); - expect(find.text('Continue'), findsNWidgets(0)); - - await tester.tap(find.text('Substep 3').first); - await tester.pumpAndSettle(); - expect(find.text('Continue'), findsOneWidget); - expect(tester.widget(find.byType(Stepper)).steps[0].state, equals(StepState.indexed)); - expect(tester.widget(find.byType(Stepper)).steps[1].state, equals(StepState.disabled)); - - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); - expect(tester.widget(find.byType(Stepper)).steps[0].state, - equals(StepState.complete)); - expect(tester.widget(find.byType(Stepper)).steps[1].state, - equals(StepState.indexed)); - }); - testWidgets('When user clicks on a previously completed step, Stepper does not navigate back.', (WidgetTester tester) async { await tester.pumpWidget( @@ -71,11 +27,10 @@ void main() { ), ); - await tester.tap(find.text('Substep 1').first); - await tester.tap(find.text('Substep 2').first); - await tester.tap(find.text('Substep 3').first); - await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Stepper)).currentStep, equals(0)); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); await tester.tap(find.text('Initialize a New Flutter Release')); await tester.pumpAndSettle();