From 29f7c7ac66cd23dfb6ebd0ea27800e5786f566dc Mon Sep 17 00:00:00 2001 From: Alex LaFroscia Date: Sun, 27 Jan 2019 13:57:09 -0800 Subject: [PATCH] feat: add route base class for observable models - Allows you to return an observable from the `model` hook of the route - Pauses the transition until the first value is emitted, just like returning a Promise - Replaces the `model` property of the controller as new values are emitted --- addon/-private/symbols.js | 3 + addon/index.js | 1 + addon/route.js | 75 +++++++++++++++ tests/acceptance/observable-model-test.js | 92 +++++++++++++++++++ tests/dummy/app/observable-model/route.js | 13 +++ tests/dummy/app/observable-model/template.hbs | 1 + tests/dummy/app/router.js | 1 + 7 files changed, 186 insertions(+) create mode 100644 addon/-private/symbols.js create mode 100644 addon/index.js create mode 100644 addon/route.js create mode 100644 tests/acceptance/observable-model-test.js create mode 100644 tests/dummy/app/observable-model/route.js create mode 100644 tests/dummy/app/observable-model/template.hbs diff --git a/addon/-private/symbols.js b/addon/-private/symbols.js new file mode 100644 index 0000000..28f54a9 --- /dev/null +++ b/addon/-private/symbols.js @@ -0,0 +1,3 @@ +export const LATER_VALUE_SUBSCRIPTION = Symbol(); +export const MOST_RECENT_VALUE = Symbol(); +export const RESET = Symbol(); diff --git a/addon/index.js b/addon/index.js new file mode 100644 index 0000000..b9872e1 --- /dev/null +++ b/addon/index.js @@ -0,0 +1 @@ +export { default as Route } from "./route"; diff --git a/addon/route.js b/addon/route.js new file mode 100644 index 0000000..878a9fb --- /dev/null +++ b/addon/route.js @@ -0,0 +1,75 @@ +import Route from "@ember/routing/route"; +import { partition, take } from "rxjs/operators"; +import { Promise as RSVPPromise } from "rsvp"; + +import { + LATER_VALUE_SUBSCRIPTION, + MOST_RECENT_VALUE, + RESET +} from "./-private/symbols"; + +export default class ObservableModelRoute extends Route { + deserialize() { + this[RESET](); + + const observable = super.deserialize(...arguments); + + const [first, later] = observable.pipe( + partition((_, index) => index === 0) + ); + + this[LATER_VALUE_SUBSCRIPTION] = later.subscribe({ + next: value => { + if (this.controller) { + this.controller.set("model", value); + } else { + this[MOST_RECENT_VALUE] = value; + } + }, + complete: () => { + this[RESET](); + } + }); + + return first.pipe(take(1)).toPromise(RSVPPromise); + } + + /** + * Reset any state that we're hanging off the instance + */ + [RESET]() { + if (this[LATER_VALUE_SUBSCRIPTION]) { + this[LATER_VALUE_SUBSCRIPTION].unsubscribe(); + } + } + + /** + * If more than one value was emitted before the controller was + * set up, we should take the most recent value as the model, not + * the original one + * + * @param {Ember.Controller} controller + * @param {any} model + */ + setupController(controller, model) { + if (this[MOST_RECENT_VALUE]) { + model = this[MOST_RECENT_VALUE]; + + this[MOST_RECENT_VALUE] = undefined; + } + + super.setupController(controller, model); + } + + resetController() { + this[RESET](); + + super.resetController && super.resetController(...arguments); + } + + willDestroy() { + this[RESET](); + + super.willDestroy && super.willDestroy(...arguments); + } +} diff --git a/tests/acceptance/observable-model-test.js b/tests/acceptance/observable-model-test.js new file mode 100644 index 0000000..02dc565 --- /dev/null +++ b/tests/acceptance/observable-model-test.js @@ -0,0 +1,92 @@ +import { module, test } from "qunit"; +import { visit } from "@ember/test-helpers"; +import { setupApplicationTest } from "ember-qunit"; +import td from "testdouble"; +import { setupScheduler } from "ember-observable/test-support"; +import { LATER_VALUE_SUBSCRIPTION } from "ember-observable/-private/symbols"; +import { of, merge } from "rxjs"; +import { delay } from "rxjs/operators"; + +module("Acceptance | observable model", function(hooks) { + setupApplicationTest(hooks); + setupScheduler(hooks); + + test("it handles multiple values over time", async function(assert) { + this.owner.lookup("route:observable-model").model = () => { + return merge( + of(1), + of(2).pipe(delay(100, this.scheduler)), + of(3).pipe(delay(200, this.scheduler)) + ); + }; + + await visit("/observable-model"); + + assert.dom().hasText("1", "Model hook resolves with the initial value"); + + await this.scheduler.advanceBy(100); + + assert.dom().hasText("2", "Model replaced with the second value"); + + await this.scheduler.advanceBy(100); + + assert.dom().hasText("3", "Model replaced with the third value"); + }); + + test("it takes the latest value if multiple are emitted before resolution", async function(assert) { + this.owner.lookup("route:observable-model").model = () => { + return of(1, 2); + }; + + await visit("/observable-model"); + + assert.dom().hasText("2", "Model used the later value"); + }); + + module("unsubscribing from later values", function(hooks) { + hooks.beforeEach(async function(assert) { + const route = this.owner.lookup("route:observable-model"); + route.model = () => { + return merge(of(1), of(2).pipe(delay(100, this.scheduler))); + }; + + await visit("/observable-model"); + + this.subscription = route[LATER_VALUE_SUBSCRIPTION]; + td.replace(this.subscription, "unsubscribe"); + + assert.verify( + this.subscription.unsubscribe(), + { times: 0 }, + "Initialled subscribed to the observable" + ); + }); + + test("when the observer completes", async function(assert) { + await this.scheduler.advanceBy(100); + + assert.verify( + this.subscription.unsubscribe(), + "Unsubscribed after completion" + ); + }); + + test("when navigating away from the route", async function(assert) { + await visit("/"); + + assert.verify( + this.subscription.unsubscribe(), + "Unsubscribed after navigating away from the page" + ); + }); + + test("when the model hook is refreshed", async function(assert) { + await visit("/observable-model?page=2"); + + assert.verify( + this.subscription.unsubscribe(), + "Unsubscribed after navigating away from the page" + ); + }); + }); +}); diff --git a/tests/dummy/app/observable-model/route.js b/tests/dummy/app/observable-model/route.js new file mode 100644 index 0000000..397ba18 --- /dev/null +++ b/tests/dummy/app/observable-model/route.js @@ -0,0 +1,13 @@ +import Route from "ember-observable/route"; + +export default class ObservableModel extends Route { + constructor() { + super(...arguments); + + this.queryParams = { + page: { + refreshModel: true + } + }; + } +} diff --git a/tests/dummy/app/observable-model/template.hbs b/tests/dummy/app/observable-model/template.hbs new file mode 100644 index 0000000..b24f939 --- /dev/null +++ b/tests/dummy/app/observable-model/template.hbs @@ -0,0 +1 @@ +{{model}} \ No newline at end of file diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index d0bb009..d9b2286 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -7,6 +7,7 @@ const Router = EmberRouter.extend({ }); Router.map(function() { + this.route('observable-model'); }); export default Router;