diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d97c937c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# 1.1.0+1 +Added `ServiceList`. \ No newline at end of file diff --git a/README.md b/README.md index 3c8f1360..6800b328 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,18 @@ Logout: ```dart await app.logout(); ``` + +# Live Updates +Oftentimes, you will want to update a collection based on updates from a service. +Use `ServiceList` for this case: + +```dart +build(BuildContext context) async { + var list = new ServiceList(app.service('api/todos')); + + return new StreamBuilder( + stream: list.onChange, + builder: _yourBuildFunction, + ); +} +``` \ No newline at end of file diff --git a/lib/angel_client.dart b/lib/angel_client.dart index 5e5f255c..e65a578d 100644 --- a/lib/angel_client.dart +++ b/lib/angel_client.dart @@ -3,6 +3,7 @@ library angel_client; import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:http/src/response.dart' as http; export 'package:angel_http_exception/angel_http_exception.dart'; @@ -132,3 +133,80 @@ abstract class Service { /// Removes the given resource. Future remove(id, [Map params]); } + +/// A [List] that automatically updates itself whenever the referenced [service] fires an event. +class ServiceList extends DelegatingList { + /// A field name used to compare [Map] by ID. + final String idField; + + /// If `true` (default: `false`), then `index` events will be handled as a [Map] containing a `data` field. + /// + /// See https://github.com/angel-dart/paginate. + final bool asPaginated; + + /// A function used to compare two items for equality. + final bool Function(dynamic, dynamic) compare; + + final Service service; + + final StreamController _onChange = new StreamController(); + final List _subs = []; + + ServiceList(this.service, {this.idField, this.asPaginated: false, this.compare}) : super([]) { + // Index + _subs.add(service.onIndexed.listen((data) { + var items = asPaginated == true ? data['data'] : data; + this..clear()..addAll(items); + })); + + // Created + _subs.add(service.onCreated.listen((item) { + add(item); + _onChange.add(this); + })); + + // Modified/Updated + handleModified(item) { + var indices = []; + + for (int i = 0; i < length; i++) { + if (compareItems(item, this[i])) + indices.add(i); + } + + if (indices.isNotEmpty) { + for (var i in indices) + this[i] = item; + + _onChange.add(this); + } + } + + _subs.addAll([ + service.onModified.listen(handleModified), + service.onUpdated.listen(handleModified), + ]); + + // Removed + _subs.add(service.onRemoved.listen((item) { + removeWhere((x) => compareItems(item, x)); + _onChange.add(this); + })); + } + + /// Fires whenever the underlying [service] fires a change event. + Stream get onChange => _onChange.stream; + + Future close() async { + _onChange.close(); + } + + bool compareItems(a, b) { + if (compare != null) + return compare(a, b); + if (a is Map) + return a[idField ?? 'id'] == b[idField ?? 'id']; + else + return a == b; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 386f028b..7b2512ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: angel_client -version: 1.1.0 +version: 1.1.0+1 description: Client library for the Angel framework. author: Tobe O homepage: https://github.com/angel-dart/angel_client @@ -7,6 +7,7 @@ environment: sdk: ">=1.21.0" dependencies: angel_http_exception: ^1.0.0 + collection: ^1.0.0 http: ">= 0.11.3 < 0.12.0" json_god: ">=2.0.0-beta <3.0.0" merge_map: ">=1.0.0 <2.0.0" diff --git a/test/list_test.dart b/test/list_test.dart new file mode 100644 index 00000000..0829a3f5 --- /dev/null +++ b/test/list_test.dart @@ -0,0 +1,54 @@ +import 'package:async/async.dart'; +import 'dart:io'; +import 'package:angel_client/io.dart' as c; +import 'package:angel_framework/angel_framework.dart' as s; +import 'package:test/test.dart'; + +main() { + HttpServer server; + c.Angel app; + c.ServiceList list; + StreamQueue queue; + + setUp(() async { + var serverApp = new s.Angel(); + serverApp.use('/api/todos', new s.MapService(autoIdAndDateFields: false)); + + server = await serverApp.startServer(); + var uri = 'http://${server.address.address}:${server.port}'; + app = new c.Rest(uri); + list = new c.ServiceList(app.service('api/todos')); + queue = new StreamQueue(list.onChange); + }); + + tearDown(() async { + await server.close(force: true); + await list.close(); + await list.service.close(); + await app.close(); + }); + + test('listens on create', () async { + list.service.create({'foo': 'bar'}); + await list.onChange.first; + expect(list, [{'foo': 'bar'}]); + }); + + test('listens on modify', () async { + list.service.create({'id': 1, 'foo': 'bar'}); + await queue.next; + + await list.service.update(1, {'id': 1, 'bar': 'baz'}); + await queue.next; + expect(list, [{'id': 1, 'bar': 'baz'}]); + }); + + test('listens on remove', () async { + list.service.create({'id': '1', 'foo': 'bar'}); + await queue.next; + + await list.service.remove('1'); + await queue.next; + expect(list, isEmpty); + }); +} \ No newline at end of file