diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91528d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Files and directories created by pub +.packages +.pub/ +build/ +# Remove the following pattern if you wish to check in your lock file +pubspec.lock + +# Directory created by dartdoc +doc/api/ + +*.iml +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60e86fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx + +COPY ./build/web /usr/share/nginx/html + +EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b17d2a3 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +## Tour of Heroes: HTTP + +Welcome to the example app used in the +[Tour of Heroes: HTTP](https://webdev.dartlang.org/angular/tutorial/toh-pt6) page +of [Dart for the web](https://webdev.dartlang.org). + +You can run a [hosted copy](https://webdev.dartlang.org/examples/toh-6) of this +sample. Or run your own copy: + +1. Create a local copy of this repo (use the "Clone or download" button above). +2. Get the dependencies: `pub get` +3. Launch a development server: `pub serve` +4. In a browser, open [http://localhost:8080](http://localhost:8080) + +In Dartium, you'll see the app right away. In other modern browsers, +you'll have to wait a bit while pub converts the app. + +--- + +*Note:* The content of this repository is generated from the +[Angular docs repository][docs repo] by running the +[dart-doc-syncer](//github.com/dart-lang/dart-doc-syncer) tool. +If you find a problem with this sample's code, please open an [issue][]. + +[docs repo]: //github.com/dart-lang/site-webdev/tree/master/examples/ng/doc/toh-6 +[issue]: //github.com/dart-lang/site-webdev/issues/new?title=examples/ng/doc/toh-6 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..97f0908 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,15 @@ +analyzer: + strong-mode: true +# exclude: +# - path/to/excluded/files/** + +# Lint rules and documentation, see http://dart-lang.github.io/linter/lints +linter: + rules: + - cancel_subscriptions + - hash_and_equals + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - test_types_in_equals + - unrelated_type_equality_checks + - valid_regexps diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..5ee639d --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: Service +metadata: + name: web-service + namespace: aqueduct-tutorial +spec: + selector: + app: aqueduct-tutorial + ports: + - port: 80 + targetPort: 80 +--- +apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: web-deployment + namespace: aqueduct-tutorial +spec: + replicas: 1 + template: + metadata: + labels: + app: aqueduct-tutorial + spec: + containers: + - name: aqueduct-tutorial + imagePullPolicy: Always + image: $IMAGE_NAME + ports: + - containerPort: 80 +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: tutorial-ingress + namespace: aqueduct-tutorial + annotations: + kubernetes.io/ingress.class: "nginx" +spec: + rules: + - host: aqueduct-tutorial.stablekernel.io + http: + paths: + - path: / + backend: + serviceName: web-service + servicePort: 80 \ No newline at end of file diff --git a/lib/app_component.css b/lib/app_component.css new file mode 100644 index 0000000..b71b158 --- /dev/null +++ b/lib/app_component.css @@ -0,0 +1,28 @@ +h1 { + font-size: 1.2em; + color: #999; + margin-bottom: 0; +} +h2 { + font-size: 2em; + margin-top: 0; + padding-top: 0; +} +nav a { + padding: 5px 10px; + text-decoration: none; + margin-top: 10px; + display: inline-block; + background-color: #eee; + border-radius: 4px; +} +nav a:visited, a:link { + color: #607D8B; +} +nav a:hover { + color: #039be5; + background-color: #CFD8DC; +} +nav a.router-link-active { + color: #039be5; +} diff --git a/lib/app_component.dart b/lib/app_component.dart new file mode 100644 index 0000000..6d117fa --- /dev/null +++ b/lib/app_component.dart @@ -0,0 +1,34 @@ +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; + +import 'package:angular_tour_of_heroes/src/heroes_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_service.dart'; +import 'package:angular_tour_of_heroes/src/dashboard_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_detail_component.dart'; + +@Component( + selector: 'my-app', + template: ''' +

{{title}}

+ + ''', + styleUrls: const ['app_component.css'], + directives: const [ROUTER_DIRECTIVES], + providers: const [HeroService], +) +@RouteConfig(const [ + const Route( + path: '/dashboard', + name: 'Dashboard', + component: DashboardComponent, + useAsDefault: true), + const Route( + path: '/detail/:id', name: 'HeroDetail', component: HeroDetailComponent), + const Route(path: '/heroes', name: 'Heroes', component: HeroesComponent) +]) +class AppComponent { + final title = 'Tour of Heroes'; +} diff --git a/lib/in_memory_data_service.dart b/lib/in_memory_data_service.dart new file mode 100644 index 0000000..528d72f --- /dev/null +++ b/lib/in_memory_data_service.dart @@ -0,0 +1,79 @@ +// Note: MockClient constructor API forces all InMemoryDataService members to +// be static. +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:angular/angular.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; + +import 'src/hero.dart'; + +@Injectable() +class InMemoryDataService extends MockClient { + static final _initialHeroes = [ + {'id': 11, 'name': 'Mr. Nice'}, + {'id': 12, 'name': 'Narco'}, + {'id': 13, 'name': 'Bombasto'}, + {'id': 14, 'name': 'Celeritas'}, + {'id': 15, 'name': 'Magneta'}, + {'id': 16, 'name': 'RubberMan'}, + {'id': 17, 'name': 'Dynama'}, + {'id': 18, 'name': 'Dr IQ'}, + {'id': 19, 'name': 'Magma'}, + {'id': 20, 'name': 'Tornado'} + ]; + static List _heroesDb; + static int _nextId; + + static Future _handler(Request request) async { + if (_heroesDb == null) resetDb(); + var data; + switch (request.method) { + case 'GET': + final id = + int.parse(request.url.pathSegments.last, onError: (_) => null); + if (id != null) { + data = _heroesDb + .firstWhere((hero) => hero.id == id); // throws if no match + } else { + String prefix = request.url.queryParameters['name'] ?? ''; + final regExp = new RegExp(prefix, caseSensitive: false); + data = _heroesDb.where((hero) => hero.name.contains(regExp)).toList(); + } + break; + case 'POST': + var name = JSON.decode(request.body)['name']; + var newHero = new Hero(_nextId++, name); + _heroesDb.add(newHero); + data = newHero; + break; + case 'PUT': + var heroChanges = new Hero.fromJson(JSON.decode(request.body)); + var targetHero = _heroesDb.firstWhere((h) => h.id == heroChanges.id); + targetHero.name = heroChanges.name; + data = targetHero; + break; + case 'DELETE': + var id = int.parse(request.url.pathSegments.last); + _heroesDb.removeWhere((hero) => hero.id == id); + // No data, so leave it as null. + break; + default: + throw 'Unimplemented HTTP method ${request.method}'; + } + return new Response(JSON.encode({'data': data}), 200, + headers: {'content-type': 'application/json'}); + } + + static resetDb() { + _heroesDb = _initialHeroes.map((json) => new Hero.fromJson(json)).toList(); + _nextId = _heroesDb.map((hero) => hero.id).fold(0, max) + 1; + } + + static String lookUpName(int id) => + _heroesDb.firstWhere((hero) => hero.id == id, orElse: null)?.name; + + InMemoryDataService() : super(_handler); +} diff --git a/lib/src/dashboard_component.css b/lib/src/dashboard_component.css new file mode 100644 index 0000000..096cec7 --- /dev/null +++ b/lib/src/dashboard_component.css @@ -0,0 +1,61 @@ +[class*='col-'] { + float: left; + padding-right: 20px; + padding-bottom: 20px; +} +[class*='col-']:last-of-type { + padding-right: 0; +} +a { + text-decoration: none; +} +*, *:after, *:before { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +h3 { + text-align: center; margin-bottom: 0; +} +h4 { + position: relative; +} +.grid { + margin: 0; +} +.col-1-4 { + width: 25%; +} +.module { + padding: 20px; + text-align: center; + color: #eee; + max-height: 120px; + min-width: 120px; + background-color: #607D8B; + border-radius: 2px; +} +.module:hover { + background-color: #EEE; + cursor: pointer; + color: #607d8b; +} +.grid-pad { + padding: 10px 0; +} +.grid-pad > [class*='col-']:last-of-type { + padding-right: 20px; +} +@media (max-width: 600px) { + .module { + font-size: 10px; + max-height: 75px; } +} +@media (max-width: 1024px) { + .grid { + margin: 0; + } + .module { + min-width: 60px; + } +} diff --git a/lib/src/dashboard_component.dart b/lib/src/dashboard_component.dart new file mode 100644 index 0000000..576554e --- /dev/null +++ b/lib/src/dashboard_component.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; + +import 'hero.dart'; +import 'hero_service.dart'; +import 'hero_search_component.dart'; + +@Component( + selector: 'my-dashboard', + templateUrl: 'dashboard_component.html', + styleUrls: const ['dashboard_component.css'], + directives: const [CORE_DIRECTIVES, HeroSearchComponent, ROUTER_DIRECTIVES], +) +class DashboardComponent implements OnInit { + List heroes; + + final HeroService _heroService; + + DashboardComponent(this._heroService); + + Future ngOnInit() async { + heroes = (await _heroService.getHeroes()).skip(1).take(4).toList(); + } +} diff --git a/lib/src/dashboard_component.html b/lib/src/dashboard_component.html new file mode 100644 index 0000000..62cb65b --- /dev/null +++ b/lib/src/dashboard_component.html @@ -0,0 +1,9 @@ +

Top Heroes

+ + diff --git a/lib/src/hero.dart b/lib/src/hero.dart new file mode 100644 index 0000000..c419070 --- /dev/null +++ b/lib/src/hero.dart @@ -0,0 +1,13 @@ +class Hero { + final int id; + String name; + + Hero(this.id, this.name); + + factory Hero.fromJson(Map hero) => + new Hero(_toInt(hero['id']), hero['name']); + + Map toJson() => {'id': id, 'name': name}; +} + +int _toInt(id) => id is int ? id : int.parse(id); diff --git a/lib/src/hero_detail_component.css b/lib/src/hero_detail_component.css new file mode 100644 index 0000000..f6139ba --- /dev/null +++ b/lib/src/hero_detail_component.css @@ -0,0 +1,29 @@ +label { + display: inline-block; + width: 3em; + margin: .5em 0; + color: #607D8B; + font-weight: bold; +} +input { + height: 2em; + font-size: 1em; + padding-left: .4em; +} +button { + margin-top: 20px; + font-family: Arial; + background-color: #eee; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; cursor: hand; +} +button:hover { + background-color: #cfd8dc; +} +button:disabled { + background-color: #eee; + color: #ccc; + cursor: auto; +} diff --git a/lib/src/hero_detail_component.dart b/lib/src/hero_detail_component.dart new file mode 100644 index 0000000..022a9d2 --- /dev/null +++ b/lib/src/hero_detail_component.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_forms/angular_forms.dart'; +import 'package:angular_router/angular_router.dart'; + +import 'hero.dart'; +import 'hero_service.dart'; + +@Component( + selector: 'hero-detail', + templateUrl: 'hero_detail_component.html', + styleUrls: const ['hero_detail_component.css'], + directives: const [CORE_DIRECTIVES, formDirectives], +) +class HeroDetailComponent implements OnInit { + Hero hero; + final HeroService _heroService; + final RouteParams _routeParams; + final Location _location; + + HeroDetailComponent(this._heroService, this._routeParams, this._location); + + Future ngOnInit() async { + var _id = _routeParams.get('id'); + var id = int.parse(_id ?? '', onError: (_) => null); + if (id != null) hero = await (_heroService.getHero(id)); + } + + Future save() async { + await _heroService.update(hero); + goBack(); + } + + void goBack() => _location.back(); +} diff --git a/lib/src/hero_detail_component.html b/lib/src/hero_detail_component.html new file mode 100644 index 0000000..5b0f9ff --- /dev/null +++ b/lib/src/hero_detail_component.html @@ -0,0 +1,11 @@ +
+

{{hero.name}} details!

+
+ {{hero.id}}
+
+ + +
+ + +
diff --git a/lib/src/hero_search_component.css b/lib/src/hero_search_component.css new file mode 100644 index 0000000..cca46f8 --- /dev/null +++ b/lib/src/hero_search_component.css @@ -0,0 +1,14 @@ +.search-result { + border-bottom: 1px solid gray; + border-left: 1px solid gray; + border-right: 1px solid gray; + width:195px; + height: 20px; + padding: 5px; + background-color: white; + cursor: pointer; +} +#search-box { + width: 200px; + height: 20px; +} diff --git a/lib/src/hero_search_component.dart b/lib/src/hero_search_component.dart new file mode 100644 index 0000000..73b15ea --- /dev/null +++ b/lib/src/hero_search_component.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import 'hero_search_service.dart'; +import 'hero.dart'; + +@Component( + selector: 'hero-search', + templateUrl: 'hero_search_component.html', + styleUrls: const ['hero_search_component.css'], + directives: const [CORE_DIRECTIVES], + providers: const [HeroSearchService], + pipes: const [COMMON_PIPES], +) +class HeroSearchComponent implements OnInit { + HeroSearchService _heroSearchService; + Router _router; + + Stream> heroes; + StreamController _searchTerms = new StreamController.broadcast(); + + HeroSearchComponent(this._heroSearchService, this._router) {} + + // Push a search term into the stream. + void search(String term) => _searchTerms.add(term); + + Future ngOnInit() async { + heroes = _searchTerms.stream + .transform(debounce(new Duration(milliseconds: 300))) + .distinct() + .transform(switchMap((term) => term.isEmpty + ? new Stream>.fromIterable([[]]) + : _heroSearchService.search(term).asStream())) + .handleError((e) { + print(e); // for demo purposes only + }); + } + + void gotoDetail(Hero hero) { + var link = [ + 'HeroDetail', + {'id': hero.id.toString()} + ]; + _router.navigate(link); + } +} diff --git a/lib/src/hero_search_component.html b/lib/src/hero_search_component.html new file mode 100644 index 0000000..e6848e8 --- /dev/null +++ b/lib/src/hero_search_component.html @@ -0,0 +1,12 @@ +
+

Hero Search

+ +
+
+ {{hero.name}} +
+
+
diff --git a/lib/src/hero_search_service.dart b/lib/src/hero_search_service.dart new file mode 100644 index 0000000..a683f80 --- /dev/null +++ b/lib/src/hero_search_service.dart @@ -0,0 +1,32 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:angular/angular.dart'; +import 'package:http/http.dart'; + +import 'hero.dart'; + +@Injectable() +class HeroSearchService { + final Client _http; + + HeroSearchService(this._http); + + Future> search(String term) async { + try { + final response = await _http.get('http://localhost:8888/heroes?name=$term'); + return _extractData(response) + .map((json) => new Hero.fromJson(json)) + .toList(); + } catch (e) { + throw _handleError(e); + } + } + + dynamic _extractData(Response resp) => JSON.decode(resp.body); + + Exception _handleError(dynamic e) { + print(e); // for demo purposes only + return new Exception('Server error; cause: $e'); + } +} diff --git a/lib/src/hero_service.dart b/lib/src/hero_service.dart new file mode 100644 index 0000000..b19cb55 --- /dev/null +++ b/lib/src/hero_service.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:angular/angular.dart'; +import 'package:http/http.dart'; + +import 'hero.dart'; + +@Injectable() +class HeroService { + static final _headers = {'Content-Type': 'application/json'}; + static const _heroesUrl = 'http://localhost:8888/heroes'; // URL to web API + + final Client _http; + + HeroService(this._http); + + Future> getHeroes() async { + try { + final response = await _http.get(_heroesUrl); + final heroes = _extractData(response) + .map((value) => new Hero.fromJson(value)) + .toList(); + return heroes; + } catch (e) { + throw _handleError(e); + } + } + + dynamic _extractData(Response resp) => JSON.decode(resp.body); + + Exception _handleError(dynamic e) { + print(e); // for demo purposes only + return new Exception('Server error; cause: $e'); + } + + Future getHero(int id) async { + try { + final response = await _http.get('$_heroesUrl/$id'); + return new Hero.fromJson(_extractData(response)); + } catch (e) { + throw _handleError(e); + } + } + + Future create(String name) async { + try { + final response = await _http.post(_heroesUrl, + headers: _headers, body: JSON.encode({'name': name})); + return new Hero.fromJson(_extractData(response)); + } catch (e) { + throw _handleError(e); + } + } + + Future update(Hero hero) async { + try { + final url = '$_heroesUrl/${hero.id}'; + final response = + await _http.put(url, headers: _headers, body: JSON.encode(hero)); + return new Hero.fromJson(_extractData(response)); + } catch (e) { + throw _handleError(e); + } + } + + Future delete(int id) async { + try { + final url = '$_heroesUrl/$id'; + await _http.delete(url, headers: _headers); + } catch (e) { + throw _handleError(e); + } + } +} diff --git a/lib/src/heroes_component.css b/lib/src/heroes_component.css new file mode 100644 index 0000000..c81f0e4 --- /dev/null +++ b/lib/src/heroes_component.css @@ -0,0 +1,66 @@ +.selected { + background-color: #CFD8DC !important; + color: white; +} +.heroes { + margin: 0 0 2em 0; + list-style-type: none; + padding: 0; + width: 15em; +} +.heroes li { + cursor: pointer; + position: relative; + left: 0; + background-color: #EEE; + margin: .5em; + padding: .3em 0; + height: 1.6em; + border-radius: 4px; +} +.heroes li:hover { + color: #607D8B; + background-color: #DDD; + left: .1em; +} +.heroes li.selected:hover { + background-color: #BBD8DC !important; + color: white; +} +.heroes .text { + position: relative; + top: -3px; +} +.heroes .badge { + display: inline-block; + font-size: small; + color: white; + padding: 0.8em 0.7em 0 0.7em; + background-color: #607D8B; + line-height: 1em; + position: relative; + left: -1px; + top: -4px; + height: 1.8em; + margin-right: .8em; + border-radius: 4px 0 0 4px; +} +button { + font-family: Arial; + background-color: #eee; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + cursor: hand; +} +button:hover { + background-color: #cfd8dc; +} +button.delete { + float:right; + margin-top: 2px; + margin-right: .8em; + background-color: gray !important; + color:white; +} diff --git a/lib/src/heroes_component.dart b/lib/src/heroes_component.dart new file mode 100644 index 0000000..40a1f91 --- /dev/null +++ b/lib/src/heroes_component.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; + +import 'hero.dart'; +import 'hero_detail_component.dart'; +import 'hero_service.dart'; + +@Component( + selector: 'my-heroes', + templateUrl: 'heroes_component.html', + styleUrls: const ['heroes_component.css'], + directives: const [CORE_DIRECTIVES, HeroDetailComponent], + pipes: const [COMMON_PIPES], +) +class HeroesComponent implements OnInit { + final HeroService _heroService; + final Router _router; + List heroes; + Hero selectedHero; + + HeroesComponent(this._heroService, this._router); + + Future getHeroes() async { + heroes = await _heroService.getHeroes(); + } + + Future add(String name) async { + name = name.trim(); + if (name.isEmpty) return; + heroes.add(await _heroService.create(name)); + selectedHero = null; + } + + Future delete(Hero hero) async { + await _heroService.delete(hero.id); + heroes.remove(hero); + if (selectedHero == hero) selectedHero = null; + } + + void ngOnInit() => getHeroes(); + + void onSelect(Hero hero) => selectedHero = hero; + + Future gotoDetail() => _router.navigate([ + 'HeroDetail', + {'id': selectedHero.id.toString()} + ]); +} diff --git a/lib/src/heroes_component.html b/lib/src/heroes_component.html new file mode 100644 index 0000000..27cdf99 --- /dev/null +++ b/lib/src/heroes_component.html @@ -0,0 +1,22 @@ +

My Heroes

+
+ + +
+
    +
  • + {{hero.id}} + {{hero.name}} + +
  • +
+
+

+ {{selectedHero.name | uppercase}} is my hero +

+ +
diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2a8babb --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,27 @@ +name: angular_tour_of_heroes +description: Tour of Heroes +version: 0.0.1 +environment: + sdk: '>=1.24.0 <2.0.0' +dependencies: + angular: ^4.0.0 + angular_forms: ^1.0.0 + angular_router: ^1.0.2 + http: ^0.11.0 + stream_transform: ^0.0.6 + +dev_dependencies: + angular_test: ^1.0.0 + browser: ^0.10.0 + dart_to_js_script_rewriter: ^1.0.1 + mockito: ^2.0.2 + test: ^0.12.21 + +transformers: +- angular: + entry_points: + - web/main.dart + - test/**_test.dart +- test/pub_serve: + $include: test/**_test.dart +- dart_to_js_script_rewriter diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..cb4d4b2 --- /dev/null +++ b/test/README.md @@ -0,0 +1,7 @@ +Changes/additions relative to toh-5: + +- Test bed: add provider for `Client`. +- Reset (mock) DB during setup. +- Test waiting for element update before proceeding. +- Test interaction with (mock) db backend. +- Use `@WithVisibleText()` PO field annotation. \ No newline at end of file diff --git a/test/all_test.dart b/test/all_test.dart new file mode 100644 index 0000000..036e63e --- /dev/null +++ b/test/all_test.dart @@ -0,0 +1,19 @@ +@Tags(const ['aot']) +@TestOn('browser') +library heroes_test; + +import 'package:angular/angular.dart'; +import 'package:test/test.dart'; + +import 'dashboard.dart' as dashboard; +import 'heroes.dart' as heroes; +import 'hero_detail.dart' as hero_detail; +import 'hero_search.dart' as hero_search; + +@AngularEntrypoint() +void main() { + group('dashboard:', dashboard.main); + group('heroes:', heroes.main); + group('hero detail:', hero_detail.main); + group('hero search:', hero_search.main); +} diff --git a/test/all_test.html b/test/all_test.html new file mode 100644 index 0000000..4c191d1 --- /dev/null +++ b/test/all_test.html @@ -0,0 +1,16 @@ + + + + + + Tests + + + + + + + + + + diff --git a/test/app_po.dart b/test/app_po.dart new file mode 100644 index 0000000..0fbcce6 --- /dev/null +++ b/test/app_po.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:pageloader/objects.dart'; +import 'utils.dart'; + +class AppPO { + @ByTagName('h1') + PageLoaderElement _h1; + + @ByCss('nav a') + List _tabLinks; + + Future get pageTitle => _h1.visibleText; + + Future> get tabTitles => + inIndexOrder(_tabLinks.map((el) => el.visibleText)).toList(); +} diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..b67827c --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,55 @@ +@Skip('AppComponent tests need bootstrap equivalent for the Router init') +@Tags(const ['aot']) +@TestOn('browser') + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_test/angular_test.dart'; +import 'package:angular_tour_of_heroes/app_component.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'app_po.dart'; + +NgTestFixture fixture; +AppPO appPO; + +final mockPlatformLocation = new MockPlatformLocation(); + +class MockPlatformLocation extends Mock implements PlatformLocation {} + +@AngularEntrypoint() +void main() { + final providers = [ + provide(APP_BASE_HREF, useValue: '/'), + provide(ROUTER_PRIMARY_COMPONENT, useValue: AppComponent), + provide(PlatformLocation, useValue: mockPlatformLocation), + ]; + + final testBed = new NgTestBed().addProviders(providers); + + setUpAll(() async { + // Seems like we'd need to do something equivalent to: + // bootstrap(AppComponent); + }); + + setUp(() async { + fixture = await testBed.create(); + appPO = await fixture.resolvePageObject(AppPO); + }); + + tearDown(disposeAnyRunningTest); + + group('Basics:', basicTests); +} + +void basicTests() { + test('page title', () async { + expect(await appPO.pageTitle, 'Tour of Heroes'); + }); + + test('tab titles', () async { + final expectTitles = ['Dashboard', 'Heroes']; + expect(await appPO.tabTitles, expectTitles); + }); +} diff --git a/test/dashboard.dart b/test/dashboard.dart new file mode 100644 index 0000000..313bd2c --- /dev/null +++ b/test/dashboard.dart @@ -0,0 +1,91 @@ +@Tags(const ['aot']) +@TestOn('browser') + +import 'dart:async'; +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_test/angular_test.dart'; +import 'package:angular_tour_of_heroes/app_component.dart'; +import 'package:angular_tour_of_heroes/in_memory_data_service.dart'; +import 'package:angular_tour_of_heroes/src/dashboard_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_service.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'dashboard_po.dart'; + +NgTestFixture fixture; +DashboardPO po; + +final mockPlatformLocation = new MockPlatformLocation(); + +class MockPlatformLocation extends Mock implements PlatformLocation {} + +@AngularEntrypoint() +void main() { + final providers = new List.from(ROUTER_PROVIDERS) + ..addAll([ + provide(APP_BASE_HREF, useValue: '/'), + provide(Client, useClass: InMemoryDataService), + provide(ROUTER_PRIMARY_COMPONENT, useValue: AppComponent), + provide(PlatformLocation, useValue: mockPlatformLocation), + HeroService, + ]); + final testBed = new NgTestBed().addProviders(providers); + + setUpAll(() async { + when(mockPlatformLocation.pathname).thenReturn(''); + when(mockPlatformLocation.search).thenReturn(''); + when(mockPlatformLocation.hash).thenReturn(''); + when(mockPlatformLocation.getBaseHrefFromDOM()).thenReturn(''); + }); + + setUp(() async { + fixture = await testBed.create(); + po = await fixture.resolvePageObject(DashboardPO); + }); + + tearDown(disposeAnyRunningTest); + + test('title', () async { + expect(await po.title, 'Top Heroes'); + }); + + test('show top heroes', () async { + final expectedNames = ['Narco', 'Bombasto', 'Celeritas', 'Magneta']; + expect(await po.heroNames, expectedNames); + }); + + test('select hero and navigate to detail', () async { + clearInteractions(mockPlatformLocation); + await po.selectHero(3); + final c = verify(mockPlatformLocation.pushState(any, any, captureAny)); + expect(c.captured.single, '/detail/15'); + }); + + test('no search no heroes', () async { + expect(await po.heroesFound, []); + }); + + group('Search hero:', heroSearchTests); +} + +void heroSearchTests() { + final matchedHeroNames = [ + 'Magneta', + 'RubberMan', + 'Dynama', + 'Magma', + ]; + + setUp(() async { + await po.search.type('ma'); + await new Future.delayed(const Duration(seconds: 1)); + po = await fixture.resolvePageObject(DashboardPO); + }); + + test('list matching heroes', () async { + expect(await po.heroesFound, matchedHeroNames); + }); +} diff --git a/test/dashboard_po.dart b/test/dashboard_po.dart new file mode 100644 index 0000000..a02b10c --- /dev/null +++ b/test/dashboard_po.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:pageloader/objects.dart'; +import 'utils.dart'; + +class DashboardPO { + @FirstByCss('h3') + PageLoaderElement _title; + + @ByTagName('a') + List _heroes; + + @ByTagName('input') + PageLoaderElement search; + + @ByCss('div[id="search-component"] div div') + List _heroesFound; + + @ByCss('div[id="search-component"]') + PageLoaderElement heroSearchDiv; + + Future get title => _title.visibleText; + + Future> get heroNames => + inIndexOrder(_heroes.map((el) => el.visibleText)).toList(); + + Future selectHero(int index) => _heroes[index].click(); + + Future> get heroesFound => + inIndexOrder(_heroesFound.map((el) => el.visibleText)).toList(); +} diff --git a/test/hero_detail.dart b/test/hero_detail.dart new file mode 100644 index 0000000..b1a8c2e --- /dev/null +++ b/test/hero_detail.dart @@ -0,0 +1,98 @@ +@Tags(const ['aot']) +@TestOn('browser') + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_test/angular_test.dart'; +import 'package:angular_tour_of_heroes/in_memory_data_service.dart'; +import 'package:angular_tour_of_heroes/src/hero_detail_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_service.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'hero_detail_po.dart'; + +NgTestFixture fixture; +HeroDetailPO po; + +class MockPlatformLocation extends Mock implements PlatformLocation {} + +final mockPlatformLocation = new MockPlatformLocation(); + +@AngularEntrypoint() +void main() { + final baseProviders = new List.from(ROUTER_PROVIDERS) + ..addAll([ + provide(APP_BASE_HREF, useValue: '/'), + provide(Client, useClass: InMemoryDataService), + provide(PlatformLocation, useValue: mockPlatformLocation), + provide(RouteParams, useValue: new RouteParams({})), + HeroService, + ]); + final testBed = + new NgTestBed().addProviders(baseProviders); + + setUp(() { + InMemoryDataService.resetDb(); + }); + + tearDown(disposeAnyRunningTest); + + test('No initial hero results in an empty view', () async { + fixture = await testBed.create(); + expect(fixture.rootElement.text.trim(), ''); + }); + + const targetHero = const {'id': 15, 'name': 'Magneta'}; + + group('${targetHero['name']} initial hero:', () { + const nameSuffix = 'X'; + final Map updatedHero = { + 'id': targetHero['id'], + 'name': "${targetHero['name']}$nameSuffix" + }; + + setUp(() async { + final groupTestBed = testBed.fork().addProviders([ + provide(RouteParams, + useValue: new RouteParams({'id': targetHero['id'].toString()})) + ]); + fixture = await groupTestBed.create(); + po = await fixture.resolvePageObject(HeroDetailPO); + }); + + test('show hero details', () async { + expect(await po.heroFromDetails, targetHero); + }); + + test('back button', () async { + await po.back(); + verify(mockPlatformLocation.back()); + }); + + group('Update name:', () { + setUp(() async { + await po.type(nameSuffix); + }); + + test('show updated name', () async { + expect(await po.heroFromDetails, updatedHero); + }); + + test('discard changes', () async { + await po.back(); + verify(mockPlatformLocation.back()); + final name = InMemoryDataService.lookUpName(targetHero['id']); + expect(name, targetHero['name']); + }); + + test('save changes and go back', () async { + await po.save(); + verify(mockPlatformLocation.back()); + final name = InMemoryDataService.lookUpName(targetHero['id']); + expect(name, updatedHero['name']); + }); + }); + }); +} diff --git a/test/hero_detail_po.dart b/test/hero_detail_po.dart new file mode 100644 index 0000000..b09d38d --- /dev/null +++ b/test/hero_detail_po.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:pageloader/objects.dart'; +import 'utils.dart'; + +class HeroDetailPO { + @FirstByCss('div h2') + PageLoaderElement _title; // e.g. 'Mr Freeze details!' + + @FirstByCss('div div') + PageLoaderElement _id; + + @ByTagName('input') + PageLoaderElement _input; + + @ByTagName('button') + @WithVisibleText('Back') + PageLoaderElement _back; + + @ByTagName('button') + @WithVisibleText('Save') + PageLoaderElement _save; + + Future get heroFromDetails async { + if (_id == null) return null; + final idAsString = (await _id.visibleText).split(':')[1]; + final text = await _title.visibleText; + final matches = new RegExp((r'^(.*) details!$')).firstMatch(text); + return heroData(idAsString, matches[1]); + } + + Future clear() => _input.clear(); + Future type(String s) => _input.type(s); + + Future back() => _back.click(); + Future save() => _save.click(); +} diff --git a/test/hero_search.dart b/test/hero_search.dart new file mode 100644 index 0000000..46f7c94 --- /dev/null +++ b/test/hero_search.dart @@ -0,0 +1,100 @@ +@Tags(const ['aot']) +@TestOn('browser') + +import 'dart:async'; + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_test/angular_test.dart'; +import 'package:angular_tour_of_heroes/app_component.dart'; +import 'package:angular_tour_of_heroes/in_memory_data_service.dart'; +import 'package:angular_tour_of_heroes/src/hero_search_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_service.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'hero_search_po.dart'; + +NgTestFixture fixture; +HeroSearchPO po; + +final mockPlatformLocation = new MockPlatformLocation(); + +class MockPlatformLocation extends Mock implements PlatformLocation {} + +@AngularEntrypoint() +void main() { + final providers = new List.from(ROUTER_PROVIDERS) + ..addAll([ + provide(APP_BASE_HREF, useValue: '/'), + provide(Client, useClass: InMemoryDataService), + provide(ROUTER_PRIMARY_COMPONENT, useValue: AppComponent), + provide(PlatformLocation, useValue: mockPlatformLocation), + HeroService, + ]); + final testBed = new NgTestBed().addProviders(providers); + + setUpAll(() async { + when(mockPlatformLocation.pathname).thenReturn(''); + when(mockPlatformLocation.search).thenReturn(''); + when(mockPlatformLocation.hash).thenReturn(''); + when(mockPlatformLocation.getBaseHrefFromDOM()).thenReturn(''); + }); + + setUp(() async { + InMemoryDataService.resetDb(); + fixture = await testBed.create(); + po = await fixture.resolvePageObject(HeroSearchPO); + }); + + tearDown(disposeAnyRunningTest); + + test('title', () async { + expect(await po.title, 'Hero Search'); + }); + + test('initially no heroes found', () async { + expect(await po.heroNames, []); + }); + + group('Search hero:', heroSearchTests); +} + +void heroSearchTests() { + final searchText = 'ma'; + + setUp(() async { + await _typeSearchTextAndRefreshPO(searchText); + }); + + test('list heroes matching ${searchText}', () async { + final matchedHeroNames = [ + 'Magneta', + 'RubberMan', + 'Dynama', + 'Magma', + ]; + expect(await po.heroNames, matchedHeroNames); + }); + + test('list heroes matching ${searchText}g', () async { + await _typeSearchTextAndRefreshPO('g'); + expect(await po.heroNames, ['Magneta', 'Magma']); + }); + + test('select hero and navigate to detail', () async { + clearInteractions(mockPlatformLocation); + await po.selectHero(0); + final c = verify(mockPlatformLocation.pushState(any, any, captureAny)); + expect(c.captured.single, '/detail/15'); + }); +} + +Future _typeSearchTextAndRefreshPO(String searchText) async { + Future firstHero; + await fixture.update((c) => firstHero = c.heroes.first); + await po.search.type(searchText); + await firstHero; + po = await fixture.resolvePageObject(HeroSearchPO); +} diff --git a/test/hero_search_po.dart b/test/hero_search_po.dart new file mode 100644 index 0000000..e86507e --- /dev/null +++ b/test/hero_search_po.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:pageloader/objects.dart'; +import 'utils.dart'; + +class HeroSearchPO { + @FirstByCss('h4') + PageLoaderElement _title; + + @ByTagName('input') + PageLoaderElement search; + + @ByCss('div[id="search-component"] div div') + List _heroes; // heroes found + + Future get title => _title.visibleText; + + Future> get heroNames => + inIndexOrder(_heroes.map((el) => el.visibleText)).toList(); + + Future selectHero(int index) => _heroes[index].click(); +} diff --git a/test/heroes.dart b/test/heroes.dart new file mode 100644 index 0000000..71990ab --- /dev/null +++ b/test/heroes.dart @@ -0,0 +1,136 @@ +@Tags(const ['aot']) +@TestOn('browser') + +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_test/angular_test.dart'; +import 'package:angular_tour_of_heroes/in_memory_data_service.dart'; +import 'package:angular_tour_of_heroes/src/heroes_component.dart'; +import 'package:angular_tour_of_heroes/src/hero_service.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'heroes_po.dart'; +import 'utils.dart'; + +const numHeroes = 10; +const targetHeroIndex = 4; // index in full heroes list +const targetHero = const {'id': 15, 'name': 'Magneta'}; + +NgTestFixture fixture; +HeroesPO po; + +final mockRouter = new MockRouter(); + +class MockRouter extends Mock implements Router {} + +@AngularEntrypoint() +void main() { + final testBed = new NgTestBed().addProviders([ + provide(Client, useClass: InMemoryDataService), + provide(Router, useValue: mockRouter), + HeroService, + ]); + + setUp(() async { + InMemoryDataService.resetDb(); + fixture = await testBed.create(); + po = await fixture.resolvePageObject(HeroesPO); + }); + + tearDown(disposeAnyRunningTest); + + group('Basics:', basicTests); + group('Selected hero:', selectedHeroTests); + group('Add hero:', addHeroTests); + group('Delete hero:', deleteHeroTests); +} + +void basicTests() { + test('title', () async { + expect(await po.title, 'My Heroes'); + }); + + test('hero count', () async { + await fixture.update(); + expect(po.heroes.length, numHeroes); + }); + + test('no selected hero', () async { + expect(await po.selectedHero, null); + }); +} + +void selectedHeroTests() { + setUp(() async { + await po.selectHero(targetHeroIndex); + po = await fixture.resolvePageObject(HeroesPO); + }); + + test('is selected', () async { + expect(await po.selectedHero, targetHero); + }); + + test('show mini-detail', () async { + expect( + await po.myHeroNameInUppercase, equalsIgnoringCase(targetHero['name'])); + }); + + test('go to detail', () async { + await po.gotoDetail(); + final c = verify(mockRouter.navigate(captureAny)); + final linkParams = [ + 'HeroDetail', + {'id': '${targetHero['id']}'} + ]; + expect(c.captured.single, linkParams); + }); + + test('select another hero', () async { + await po.selectHero(0); + po = await fixture.resolvePageObject(HeroesPO); + final heroData = {'id': 11, 'name': 'Mr. Nice'}; + expect(await po.selectedHero, heroData); + }); +} + +void addHeroTests() { + const newHeroName = 'Carl'; + + setUp(() async { + await po.addHero(newHeroName); + po = await fixture.resolvePageObject(HeroesPO); + }); + + test('hero count', () async { + expect(po.heroes.length, numHeroes + 1); + }); + + test('select new hero', () async { + await po.selectHero(numHeroes); + po = await fixture.resolvePageObject(HeroesPO); + expect(po.heroes.length, numHeroes + 1); + expect((await po.selectedHero)['name'], newHeroName); + expect(await po.myHeroNameInUppercase, equalsIgnoringCase(newHeroName)); + }); +} + +void deleteHeroTests() { + List heroesWithoutTarget = []; + + setUp(() async { + heroesWithoutTarget = await inIndexOrder(po.heroes).toList() + ..removeAt(targetHeroIndex); + await po.deleteHero(targetHeroIndex); + po = await fixture.resolvePageObject(HeroesPO); + }); + + test('hero count', () async { + expect(po.heroes.length, numHeroes - 1); + }); + + test('heroes left', () async { + expect(await inIndexOrder(po.heroes).toList(), heroesWithoutTarget); + }); +} diff --git a/test/heroes_po.dart b/test/heroes_po.dart new file mode 100644 index 0000000..417ad73 --- /dev/null +++ b/test/heroes_po.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:pageloader/objects.dart'; +import 'utils.dart'; + +class HeroesPO { + @FirstByCss('h2') + PageLoaderElement _title; + + @ByTagName('li') + List _heroes; + + @ByTagName('li') + @WithClass('selected') + @optional + PageLoaderElement _selectedHero; + + @FirstByCss('div h2') + @optional + PageLoaderElement _miniDetailHeading; + + @ByTagName('button') + @WithVisibleText('View Details') + @optional + PageLoaderElement _gotoDetail; + + @ByCss('button') + @WithVisibleText('Add') + PageLoaderElement _add; + + @ByCss('li button') + List _deleteHeroes; + + @ByTagName('input') + PageLoaderElement _input; + + Future get title => _title.visibleText; + + Iterable> get heroes => + _heroes.map((el) async => _heroDataFromLi(await el.visibleText)); + + Future selectHero(int index) => _heroes[index].click(); + Future deleteHero(int index) => _deleteHeroes[index].click(); + + Future get selectedHero async => _selectedHero == null + ? null + : _heroDataFromLi(await _selectedHero.visibleText); + + Future get myHeroNameInUppercase async { + if (_miniDetailHeading == null) return null; + final text = await _miniDetailHeading.visibleText; + final matches = new RegExp((r'^(.*) is my hero\s*$')).firstMatch(text); + return matches[1]; + } + + Future addHero(String name) async { + await _input.clear(); + await _input.type(name); + return _add.click(); + } + + Future gotoDetail() async => _gotoDetail.click(); + + Map _heroDataFromLi(String liText) { + final matches = new RegExp((r'^(\d+) (.*) x$')).firstMatch(liText); + return heroData(matches[1], matches[2]); + } +} diff --git a/test/utils.dart b/test/utils.dart new file mode 100644 index 0000000..a412011 --- /dev/null +++ b/test/utils.dart @@ -0,0 +1,8 @@ +import 'dart:async'; + +Map heroData(String idAsString, String name) => + {'id': int.parse(idAsString, onError: (_) => -1), 'name': name}; + +Stream inIndexOrder(Iterable> futures) async* { + for (var x in futures) yield await x; +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..c90bc46 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ff3431a --- /dev/null +++ b/web/index.html @@ -0,0 +1,25 @@ + + + + + + Angular Tour of Heroes + + + + + + + + + + Loading... + + diff --git a/web/main.dart b/web/main.dart new file mode 100644 index 0000000..addd67e --- /dev/null +++ b/web/main.dart @@ -0,0 +1,29 @@ +import 'package:angular/angular.dart'; +import 'package:angular_router/angular_router.dart'; +import 'package:angular_tour_of_heroes/app_component.dart'; +import 'package:angular_tour_of_heroes/in_memory_data_service.dart'; +import 'package:http/http.dart'; +import 'package:http/browser_client.dart'; + +void main() { + bootstrap(AppComponent, [ + ROUTER_PROVIDERS, + // Remove next line in production + provide(LocationStrategy, useClass: HashLocationStrategy), + // Using a real back end? + // Import browser_client.dart and change the above to: + provide(Client, useFactory: () => new BrowserClient(), deps: []) + ]); +} +/* +import 'package:http/browser_client.dart'; + +void main() { + bootstrap(AppComponent, [ + ROUTER_PROVIDERS, + // Remove next line in production + provide(LocationStrategy, useClass: HashLocationStrategy), + provide(BrowserClient, useFactory: () => new BrowserClient(), deps: []) + ]); +} +*/ diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..9e6bc1f --- /dev/null +++ b/web/styles.css @@ -0,0 +1,115 @@ +@import url(https://fonts.googleapis.com/css?family=Roboto); +@import url(https://fonts.googleapis.com/css?family=Material+Icons); + +/* Master Styles */ +h1 { + color: #369; + font-family: Arial, Helvetica, sans-serif; + font-size: 250%; +} +h2, h3 { + color: #444; + font-family: Arial, Helvetica, sans-serif; + font-weight: lighter; +} +body { + margin: 2em; +} +body, input[text], button { + color: #888; + font-family: Cambria, Georgia; +} +a { + cursor: pointer; + cursor: hand; +} +button { + font-family: Arial; + background-color: #eee; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + cursor: hand; +} +button:hover { + background-color: #cfd8dc; +} +button:disabled { + background-color: #eee; + color: #aaa; + cursor: auto; +} + +/* Navigation link styles */ +nav a { + padding: 5px 10px; + text-decoration: none; + margin-right: 10px; + margin-top: 10px; + display: inline-block; + background-color: #eee; + border-radius: 4px; +} +nav a:visited, a:link { + color: #607D8B; +} +nav a:hover { + color: #039be5; + background-color: #CFD8DC; +} +nav a.active { + color: #039be5; +} + +/* items class */ +.items { + margin: 0 0 2em 0; + list-style-type: none; + padding: 0; + width: 24em; +} +.items li { + cursor: pointer; + position: relative; + left: 0; + background-color: #EEE; + margin: .5em; + padding: .3em 0; + height: 1.6em; + border-radius: 4px; +} +.items li:hover { + color: #607D8B; + background-color: #DDD; + left: .1em; +} +.items li.selected { + background-color: #CFD8DC; + color: white; +} +.items li.selected:hover { + background-color: #BBD8DC; +} +.items .text { + position: relative; + top: -3px; +} +.items .badge { + display: inline-block; + font-size: small; + color: white; + padding: 0.8em 0.7em 0 0.7em; + background-color: #607D8B; + line-height: 1em; + position: relative; + left: -1px; + top: -4px; + height: 1.8em; + margin-right: .8em; + border-radius: 4px 0 0 4px; +} +/* everywhere else */ +* { + font-family: Arial, Helvetica, sans-serif; +}