From bb368721cfab447528c0131f77783ef4544dce20 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 21 Dec 2017 14:24:49 -0500 Subject: [PATCH] 1.0.0 --- .../fires_modified_in_all_test_dart.xml | 8 + .../fires_removed_in_all_test_dart.xml | 8 + .../tests_in_all_test_dart.xml | 6 + .travis.yml | 1 + CHANGELOG.md | 2 + README.md | 42 +++- example/main.dart | 21 ++ lib/angel_poll.dart | 201 ++++++++++++++++-- pubspec.yaml | 8 + test/all_test.dart | 106 +++++++++ 10 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 .idea/runConfigurations/fires_modified_in_all_test_dart.xml create mode 100644 .idea/runConfigurations/fires_removed_in_all_test_dart.xml create mode 100644 .idea/runConfigurations/tests_in_all_test_dart.xml create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 example/main.dart create mode 100644 test/all_test.dart diff --git a/.idea/runConfigurations/fires_modified_in_all_test_dart.xml b/.idea/runConfigurations/fires_modified_in_all_test_dart.xml new file mode 100644 index 00000000..5f69f16d --- /dev/null +++ b/.idea/runConfigurations/fires_modified_in_all_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/fires_removed_in_all_test_dart.xml b/.idea/runConfigurations/fires_removed_in_all_test_dart.xml new file mode 100644 index 00000000..cff1a811 --- /dev/null +++ b/.idea/runConfigurations/fires_removed_in_all_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/tests_in_all_test_dart.xml b/.idea/runConfigurations/tests_in_all_test_dart.xml new file mode 100644 index 00000000..dd79afb1 --- /dev/null +++ b/.idea/runConfigurations/tests_in_all_test_dart.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6ae2c706 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# 1.0.0 +* Created package + tests \ No newline at end of file diff --git a/README.md b/README.md index b500691a..de25152b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # poll -package:angel_client upport for "realtime" interactions with Angel via long polling. +[![Pub](https://img.shields.io/pub/v/angel_poll.svg)](https://pub.dartlang.org/packages/angel_poll) +[![build status](https://travis-ci.org/angel-dart/poll.svg?branch=master)](https://travis-ci.org/angel-dart/poll) + +`package:angel_client` support for "realtime" interactions with Angel via long polling. + +Angel supports [WebSockets](https://github.com/angel-dart/websocket) on the server and client, which +makes it very straightforward to implement realtime collections. However, not every user's browser +supports WebSockets. In such a case, applications might *gracefully degrade* to long-polling +the server for changes. + +A `PollingService` wraps a client-side `Service` (typically a REST-based one), and calls its +`index` method at a regular interval. After indexing, the `PollingService` performs a diff +and identifies whether items have been created, modified, or removed. The updates are sent out +through `onCreated`, `onModified`, etc., effectively managing a real-time collection of data. + +A common use-case would be passing this service to `ServiceList`, a class that manages the state +of a collection managed in real-time. + +```dart +import 'package:angel_client/io.dart'; +import 'package:angel_poll/angel_poll.dart'; + +main() { + var app = new Rest('http://localhost:3000'); + + var todos = new ServiceList( + new PollingService( + // Typically, you'll pass a REST-based service instance here. + app.service('api/todos'), + + // `index` called every 5 seconds + const Duration(seconds: 5), + ), + ); + + todos.onChange.listen((_) { + // Something happened here. + // Maybe an item was created, modified, etc. + }); +} +``` \ No newline at end of file diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 00000000..8ac5c6da --- /dev/null +++ b/example/main.dart @@ -0,0 +1,21 @@ +import 'package:angel_client/io.dart'; +import 'package:angel_poll/angel_poll.dart'; + +main() { + var app = new Rest('http://localhost:3000'); + + var todos = new ServiceList( + new PollingService( + // Typically, you'll pass a REST-based service instance here. + app.service('api/todos'), + + // `index` called every 5 seconds + const Duration(seconds: 5), + ), + ); + + todos.onChange.listen((_) { + // Something happened here. + // Maybe an item was created, modified, etc. + }); +} diff --git a/lib/angel_poll.dart b/lib/angel_poll.dart index 5e30caa4..7aab6701 100644 --- a/lib/angel_poll.dart +++ b/lib/angel_poll.dart @@ -1,13 +1,46 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:angel_client/angel_client.dart'; -class Poll extends Service { +/// A [Service] that facilitates real-time updates via the long polling of an [inner] service. +/// +/// Works well with [ServiceList]. +class PollingService extends Service { + /// The underlying [Service] that does the actual communication with the server. final Service inner; + /// Perform computations after polling to discern whether new items were created. + final bool checkForCreated; + + /// Perform computations after polling to discern whether items were modified. + final bool checkForModified; + + /// Perform computations after polling to discern whether items were removed. + final bool checkForRemoved; + + /// An [EqualityBy] used to compare the ID's of two items. + /// + /// Defaults to comparing the [idField] of two `Map` instances. + final EqualityBy compareId; + + /// An [Equality] used to discern whether two items, with the same [idField], are the same item. + /// + /// Defaults to [MapEquality], which deep-compares `Map` instances. + final Equality compareItems; + + /// A [String] used as an index through which to compare `Map` instances. + /// + /// Defaults to `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; final List _items = []; + final List _subs = []; + final StreamController _onIndexed = new StreamController(), _onRead = new StreamController(), _onCreated = new StreamController(), @@ -15,7 +48,6 @@ class Poll extends Service { _onUpdated = new StreamController(), _onRemoved = new StreamController(); - bool Function(dynamic, dynamic) _compare; Timer _timer; @override @@ -39,15 +71,43 @@ class Poll extends Service { @override Stream get onRemoved => _onRemoved.stream; - Poll(this.inner, Duration interval, - {this.idField: 'id', this.asPaginated: false, bool compare(a, b)}) { - _timer = new Timer.periodic(interval, _timerCallback); - _compare = compare ?? (a, b) => a[idField ?? 'id'] == b[idField ?? 'id']; + PollingService(this.inner, Duration interval, + {this.checkForCreated: true, + this.checkForModified: true, + this.checkForRemoved: true, + this.idField: 'id', + this.asPaginated: false, + EqualityBy compareId, + this.compareItems: const MapEquality()}) + : compareId = compareId ?? new EqualityBy((map) => map[idField ?? 'id']) { + _timer = new Timer.periodic(interval, (_) { + index().catchError(_onIndexed.addError); + }); + + var streams = { + inner.onRead: _onRead, + inner.onCreated: _onCreated, + inner.onModified: _onModified, + inner.onUpdated: _onUpdated, + inner.onRemoved: _onRemoved, + }; + + streams.forEach((stream, ctrl) { + _subs.add(stream.listen(ctrl.add, onError: ctrl.addError)); + }); + + _subs.add( + inner.onIndexed.listen( + _handleIndexed, + onError: _onIndexed.addError, + ), + ); } @override Future close() async { _timer.cancel(); + _subs.forEach((s) => s.cancel()); _onIndexed.close(); _onRead.close(); _onCreated.close(); @@ -59,35 +119,134 @@ class Poll extends Service { @override Future index([Map params]) { return inner.index().then((data) { - var items = asPaginated == true ? data['data'] : data; - _items - ..clear() - ..addAll(items); - _onIndexed.add(items); + return asPaginated == true ? data['data'] : data; }); } @override - Future remove(id, [Map params]) {} + Future remove(id, [Map params]) { + return inner.remove(id, params).then((result) { + _items.remove(result); + return result; + }).catchError(_onRemoved.addError); + } + + _handleUpdate(result) { + int index = -1; + + for (int i = 0; i < _items.length; i++) { + if (compareId.equals(_items[i], result)) { + index = i; + break; + } + } + + if (index > -1) { + _items[index] = result; + } + + return result; + } @override - Future update(id, data, [Map params]) {} + Future update(id, data, [Map params]) { + return inner + .update(id, data, params) + .then(_handleUpdate) + .catchError(_onUpdated.addError); + } @override - Future modify(id, data, [Map params]) {} + Future modify(id, data, [Map params]) { + return inner + .modify(id, data, params) + .then(_handleUpdate) + .catchError(_onModified.addError); + } @override - Future create(data, [Map params]) {} + Future create(data, [Map params]) { + return inner.create(data, params).then((result) { + _items.add(result); + return result; + }).catchError(_onCreated.addError); + } @override - Future read(id, [Map params]) {} + Future read(id, [Map params]) { + return inner.read(id, params); + } - void _timerCallback(Timer timer) { - index().then((data) { - var items = asPaginated == true ? data['data'] : data; + void _handleIndexed(data) { + var items = asPaginated == true ? data['data'] : data; + bool changesComputed = false; - // TODO: Check create, modify, remove + if (checkForCreated != false) { + var newItems = {}; - }).catchError(_onIndexed.addError); + for (int i = 0; i < items.length; i++) { + var item = items[i]; + + if (!_items.any((i) => compareId.equals(i, item))) { + newItems[i] = item; + } + } + + newItems.forEach((index, item) { + _items.insert(index, item); + _onCreated.add(item); + }); + + changesComputed = newItems.isNotEmpty; + } + + if (checkForRemoved != false) { + var removedItems = {}; + + for (int i = 0; i < _items.length; i++) { + var item = _items[i]; + + if (!items.any((i) => compareId.equals(i, item))) { + removedItems[i] = item; + } + } + + removedItems.forEach((index, item) { + _items.removeAt(index); + _onRemoved.add(item); + }); + + changesComputed = changesComputed || removedItems.isNotEmpty; + } + + if (checkForModified != false) { + var modifiedItems = {}; + + for (var item in items) { + for (int i = 0; i < _items.length; i++) { + var localItem = _items[i]; + + if (compareId.equals(item, localItem)) { + if (!compareItems.equals(item, localItem)) { + modifiedItems[i] = item; + } + break; + } + } + } + + modifiedItems.forEach((index, item) { + _onModified.add(_items[index] = item); + }); + + changesComputed = changesComputed || modifiedItems.isNotEmpty; + } + + if (!changesComputed) { + _items + ..clear() + ..addAll(items); + _onIndexed.add(items); + } } } diff --git a/pubspec.yaml b/pubspec.yaml index 5b9b9034..a083dc33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,14 @@ name: angel_poll +version: 1.0.0 +description: package:angel_client support for "realtime" interactions with Angel via long polling. +author: Tobe O +environment: + sdk: ">=1.19.0" +homepage: https://github.com/angel-dart/poll dependencies: angel_client: ^1.0.0 + async: ">=1.10.0 <3.0.0" + collection: ^1.0.0 dev_dependencies: angel_test: ^1.1.0 test: ^0.12.0 \ No newline at end of file diff --git a/test/all_test.dart b/test/all_test.dart new file mode 100644 index 00000000..67d6b23a --- /dev/null +++ b/test/all_test.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart' as srv; +import 'package:angel_poll/angel_poll.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:async/async.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +void main() { + srv.Service store; + TestClient client; + PollingService pollingService; + + setUp(() async { + var app = new srv.Angel(); + app.logger = new Logger.detached('angel_poll') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) { + print(rec.error); + print(rec.stackTrace); + } + }); + + store = app.use( + '/api/todos', + new srv.MapService( + autoIdAndDateFields: false, + ), + ); + + client = await connectTo(app); + + pollingService = new PollingService( + client.service('api/todos'), + const Duration(milliseconds: 100), + ); + }); + + tearDown(() => client.close()); + + group('events', () { + var created; + StreamQueue onCreated, onModified, onRemoved; + + setUp(() async { + onCreated = new StreamQueue(pollingService.onCreated); + onModified = new StreamQueue(pollingService.onModified); + onRemoved = new StreamQueue(pollingService.onRemoved); + + created = await store.create({ + 'id': '0', + 'text': 'Clean your room', + 'completed': false, + }); + }); + + tearDown(() { + onCreated.cancel(); + onModified.cancel(); + onRemoved.cancel(); + }); + + test('fires indexed', () async { + var indexed = await pollingService.index(); + print(indexed); + expect(await pollingService.onIndexed.first, indexed); + }); + + test('fires created', () async { + var result = await onCreated.next; + print(result); + expect(created, result); + }); + + test('fires modified', () async { + await pollingService.index(); + await store.modify('0', { + 'text': 'go to school', + }); + + var result = await onModified.next; + print(result); + expect(result, new Map.from(created)..['text'] = 'go to school'); + }); + + test('manual modify', () async { + await pollingService.index(); + await pollingService.modify('0', { + 'text': 'eat', + }); + + var result = await onModified.next; + print(result); + expect(result, new Map.from(created)..['text'] = 'eat'); + }); + + test('fires removed', () async { + await pollingService.index(); + var removed = await store.remove('0'); + var result = await onRemoved.next; + print(result); + expect(result, removed); + }); + }); +}