Skip to content

Commit

Permalink
feat: add route base class for observable models
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
alexlafroscia committed Jan 27, 2019
1 parent 0001781 commit 29f7c7a
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 0 deletions.
3 changes: 3 additions & 0 deletions addon/-private/symbols.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const LATER_VALUE_SUBSCRIPTION = Symbol();
export const MOST_RECENT_VALUE = Symbol();
export const RESET = Symbol();
1 change: 1 addition & 0 deletions addon/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Route } from "./route";
75 changes: 75 additions & 0 deletions addon/route.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
92 changes: 92 additions & 0 deletions tests/acceptance/observable-model-test.js
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
});
13 changes: 13 additions & 0 deletions tests/dummy/app/observable-model/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Route from "ember-observable/route";

export default class ObservableModel extends Route {
constructor() {
super(...arguments);

this.queryParams = {
page: {
refreshModel: true
}
};
}
}
1 change: 1 addition & 0 deletions tests/dummy/app/observable-model/template.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{model}}
1 change: 1 addition & 0 deletions tests/dummy/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const Router = EmberRouter.extend({
});

Router.map(function() {
this.route('observable-model');
});

export default Router;

0 comments on commit 29f7c7a

Please sign in to comment.