diff --git a/.eslintrc.js b/.eslintrc.js index b68950a2..b0bd799d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,15 +23,16 @@ module.exports = { // node files { files: [ + 'ember-cli-build.js', 'index.js', 'testem.js', - 'ember-cli-build.js', 'config/**/*.js', 'tests/dummy/config/**/*.js' ], excludedFiles: [ - 'app/**', 'addon/**', + 'addon-test-support/**', + 'app/**', 'tests/dummy/app/**' ], parserOptions: { diff --git a/addon/mixins/loadable-model.js b/addon/mixins/loadable-model.js index 8417e99b..1c6d2d42 100644 --- a/addon/mixins/loadable-model.js +++ b/addon/mixins/loadable-model.js @@ -1,4 +1,9 @@ import Mixin from '@ember/object/mixin'; +import { assert } from '@ember/debug'; +import { resolve } from 'rsvp'; +import { isArray } from '@ember/array'; +import { get } from '@ember/object'; +import { camelize } from '@ember/string'; /** _This mixin relies on JSON:API, and assumes that your server supports JSON:API includes._ @@ -30,11 +35,16 @@ import Mixin from '@ember/object/mixin'; */ export default Mixin.create({ + init() { + this._super(...arguments); + this.set('_loadedReferences', {}); + }, + /** - `load` gives you an explicit way to asynchronously load related data. + `reloadWith` gives you an explicit way to asynchronously reloadWith related data. ```js - post.load('comments'); + post.reloadWith('comments'); ``` The above uses Storefront's `loadRecord` method to query your backend for the post along with its comments. @@ -42,25 +52,25 @@ export default Mixin.create({ You can also use JSON:API's dot notation to load additional related relationships. ```js - post.load('comments.author'); + post.reloadWith('comments.author'); ``` - Every call to `load()` will return a promise. + Every call to `reloadWith()` will return a promise. ```js - post.load('comments').then(() => console.log('loaded comments!')); + post.reloadWith('comments').then(() => console.log('loaded comments!')); ``` - If a relationship has never been loaded, the promise will block until the data is loaded. However, if a relationship has already been loaded (even from calls to `loadRecord` elsewhere in your application), the promise will resolve synchronously with the data from Storefront's cache. This means you don't have to worry about overcalling `load()`. + If a relationship has never been loaded, the promise will block until the data is loaded. However, if a relationship has already been loaded (even from calls to `loadRecord` elsewhere in your application), the promise will resolve synchronously with the data from Storefront's cache. This means you don't have to worry about overcalling `reloadWith()`. This feature works best when used on relationships that are defined with `{ async: false }` because it allows `load()` to load the data, and `get()` to access the data that has already been loaded. - @method load + @method reloadWith @param {String} includesString a JSON:API includes string representing the relationships to check @return {Promise} a promise resolving with the record @public */ - load(...includes) { + reloadWith(...includes) { let modelName = this.constructor.modelName; return this.get('store').loadRecord(modelName, this.get('id'), { @@ -68,6 +78,111 @@ export default Mixin.create({ }); }, + /** + `load` gives you an explicit way to asynchronously load related data. + + ```js + post.load('comments'); + ``` + + The above uses Ember data's references API to load a post's comments from your backend. + + Every call to `load()` will return a promise. + + ```js + post.load('comments').then((comments) => console.log('loaded comments as', comments)); + ``` + + If a relationship has never been loaded, the promise will block until the data is loaded. However, if a relationship has already been loaded, the promise will resolve synchronously with the data from the cache. This means you don't have to worry about overcalling `load()`. + + @method load + @param {String} name the name of the relationship to load + @return {Promise} a promise resolving with the related data + @public + */ + load(name, options = {}) { + assert( + `The #load method only works with a single relationship, if you need to load multiple relationships in one request please use the #reloadWith method [ember-data-storefront]`, + !isArray(name) && !name.includes(',') && !name.includes('.') + ); + + let reference = this._getReference(name); + let value = reference.value(); + let shouldBlock = !(value || this.hasLoaded(name)) || options.reload; + let promise; + + if (shouldBlock) { + let loadMethod = this._getLoadMethod(name, options); + promise = reference[loadMethod].call(reference); + } else { + promise = resolve(value); + reference.reload(); + } + + return promise.then(data => { + // need to track that we loaded this relationship, since relying on the reference's + // value existing is not enough + this._loadedReferences[name] = true; + return data; + }); + }, + + /** + @method _getRelationshipInfo + @private + */ + _getRelationshipInfo(name) { + let relationshipInfo = get(this.constructor, `relationshipsByName`).get(name); + + assert( + `You tried to load the relationship ${name} for a ${this.constructor.modelName}, but that relationship does not exist [ember-data-storefront]`, + relationshipInfo + ); + + return relationshipInfo; + }, + + /** + @method _getReference + @private + */ + _getReference(name) { + let relationshipInfo = this._getRelationshipInfo(name); + let referenceMethod = relationshipInfo.kind; + return this[referenceMethod](name); + }, + + /** + Given a relationship name this method will return the best way to load + that relationship. + + @method _getLoadMethod + @private + */ + _getLoadMethod(name, options) { + let relationshipInfo = this._getRelationshipInfo(name); + let reference = this._getReference(name); + let hasLoaded = this._hasLoadedReference(name); + let forceReload = options.reload; + let isAsync; + + if (relationshipInfo.kind === 'hasMany') { + isAsync = reference.hasManyRelationship.isAsync; + } else if (relationshipInfo.kind === 'belongsTo') { + isAsync = reference.belongsToRelationship.isAsync; + } + + return !forceReload && isAsync && !hasLoaded ? 'load' : 'reload'; + }, + + /** + @method _hasLoadedReference + @private + */ + _hasLoadedReference(name) { + return this._loadedReferences[name]; + }, + /** This method returns true if the provided includes string has been loaded and false if not. @@ -78,8 +193,10 @@ export default Mixin.create({ */ hasLoaded(includesString) { let modelName = this.constructor.modelName; + let hasSideloaded = this.get('store').hasLoadedIncludesForRecord(modelName, this.get('id'), includesString); + let hasLoaded = this._hasLoadedReference(camelize(includesString)); - return this.get('store').hasLoadedIncludesForRecord(modelName, this.get('id'), includesString); + return hasLoaded || hasSideloaded; } }); diff --git a/addon/mixins/loadable-store.js b/addon/mixins/loadable-store.js index 7e057dc9..1428d1b0 100644 --- a/addon/mixins/loadable-store.js +++ b/addon/mixins/loadable-store.js @@ -146,7 +146,8 @@ export default Mixin.create({ return this.coordinator.recordHasIncludes(type, id, includesString); }, -/** + /** + @method resetCache @private */ resetCache() { diff --git a/config/ember-try.js b/config/ember-try.js index d8bbb60c..f40a4a79 100644 --- a/config/ember-try.js +++ b/config/ember-try.js @@ -55,11 +55,30 @@ module.exports = function() { } } }, + { + name: 'ember-3.1', + npm: { + devDependencies: { + 'ember-source': '~3.1.0', + 'ember-data': "~3.1.0" + } + } + }, + { + name: 'ember-3.4', + npm: { + devDependencies: { + 'ember-source': '~3.4.0', + 'ember-data': "~3.4.0" + } + } + }, { name: 'ember-release', npm: { devDependencies: { - 'ember-source': urls[0] + 'ember-source': urls[0], + 'ember-data': 'emberjs/data#release' } } }, @@ -77,7 +96,7 @@ module.exports = function() { npm: { devDependencies: { 'ember-source': urls[2], - 'ember-data': 'canary' + 'ember-data': 'emberjs/data' } } }, diff --git a/package.json b/package.json index 9f3dfef9..1e172e9c 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "devDependencies": { "broccoli-asset-rev": "^2.4.5", "ember-ajax": "^3.0.0", - "ember-cli": "~3.0.0", - "ember-cli-addon-docs": "^0.5.0", + "ember-cli": "~3.1.0", + "ember-cli-addon-docs": "ryanto/ember-cli-addon-docs#97dcf3bc8478e45c217a38d15e2117df2e18c32e", "ember-cli-addon-docs-yuidoc": "^0.1.1", "ember-cli-dependency-checker": "^2.0.0", "ember-cli-deploy": "^1.0.2", @@ -54,7 +54,7 @@ "ember-cli-uglify": "^2.0.0", "ember-component-css": "^0.5.0", "ember-concurrency": "^0.8.12", - "ember-data": "~3.0.0", + "ember-data": "~3.1.0", "ember-disable-prototype-extensions": "^1.1.2", "ember-export-application-global": "^2.0.0", "ember-fetch": "^5.0.0", @@ -63,14 +63,13 @@ "ember-maybe-import-regenerator": "^0.1.6", "ember-qunit-assert-helpers": "^0.2.1", "ember-resolver": "^4.0.0", - "ember-router-scroll": "ryanto/ember-router-scroll#983cb421f5295f6d88119be16aec4dcf662a948f", - "ember-source": "~3.0.0", + "ember-router-scroll": "~1.0.0", + "ember-source": "~3.1.0", "ember-source-channel-url": "^1.0.1", "ember-test-selectors": "^0.3.8", "ember-try": "^0.2.23", - "ember-wait-for-test-helper": "^2.1.1", "eslint-plugin-ember": "^5.0.0", - "eslint-plugin-node": "^5.2.1", + "eslint-plugin-node": "^6.0.1", "express": "^4.8.5", "glob": "^4.0.5", "jsdom": "^11.6.2", diff --git a/tests/acceptance/load-all-test.js b/tests/acceptance/load-all-test.js index 9c7a72a4..439c1cdc 100644 --- a/tests/acceptance/load-all-test.js +++ b/tests/acceptance/load-all-test.js @@ -1,7 +1,6 @@ import { module, test } from 'qunit'; -import { visit, click, find } from "@ember/test-helpers"; +import { visit, click, find, waitUntil } from "@ember/test-helpers"; import { setupApplicationTest } from 'ember-qunit'; -import { waitFor } from 'ember-wait-for-test-helper/wait-for'; import { startMirage } from 'dummy/initializers/ember-cli-mirage'; function t(...args) { @@ -12,7 +11,7 @@ function t(...args) { async function domHasChanged(selector) { let previousUi = find(selector).textContent; - return await waitFor(() => { + return await waitUntil(() => { let currentUi = find(selector).textContent; return currentUi !== previousUi; @@ -33,6 +32,9 @@ module('Acceptance | load all', function(hooks) { }); test('visiting /load-all', async function(assert) { + // need our data fetching to be slow for these tests. + server.timing = 1000; + server.create('post', { id: '1', title: 'Post 1 title' }); server.create('post'); diff --git a/tests/acceptance/load-relationship-test.js b/tests/acceptance/load-relationship-test.js index d4f51571..37e7dcad 100644 --- a/tests/acceptance/load-relationship-test.js +++ b/tests/acceptance/load-relationship-test.js @@ -1,32 +1,31 @@ import { module, test } from 'qunit'; import { visit, click, find } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; -import { startMirage } from 'dummy/initializers/ember-cli-mirage'; +import startMirage from 'dummy/tests/helpers/start-mirage'; module('Acceptance | load relationship', function(hooks) { - let server; - setupApplicationTest(hooks); + startMirage(hooks); - hooks.beforeEach(function() { - server = startMirage(); - }); + test('the load demo works', async function(assert) { + await visit('/docs/guides/working-with-relationships'); - hooks.afterEach(function() { - server.shutdown(); - }); + await click('[data-test-id=load-comments]'); - test('visiting /load-relationship', async function(assert) { - let post = server.create('post', { id: '1', title: 'Post 1 title' }); - server.createList('comment', 3, { post }); + assert.equal( + find('[data-test-id=load-comments-count]').textContent.trim(), + "The post has 3 comments." + ); + }); + test('the reloadWith demo works', async function(assert) { await visit('/docs/guides/working-with-relationships'); - await click('[data-test-id=load-comments]'); + await click('[data-test-id=reload-with-comments]'); assert.equal( - find('[data-test-id=comments-count]').textContent.trim(), - "The post has 3 comments." + find('[data-test-id=reload-with-comments-count]').textContent.trim(), + "The post has 5 comments." ); }); }); diff --git a/tests/dummy/app/app.js b/tests/dummy/app/app.js index 6751dfa3..88051453 100644 --- a/tests/dummy/app/app.js +++ b/tests/dummy/app/app.js @@ -4,6 +4,7 @@ import loadInitializers from 'ember-load-initializers'; import config from './config/environment'; import DS from 'ember-data'; import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; +import { registerWarnHandler } from '@ember/debug'; DS.Model.reopen(LoadableModel); @@ -13,6 +14,15 @@ const App = Application.extend({ Resolver }); +// We'll ignore the empty tag name warning for test selectors since we have +// empty tag names for pass through components. +registerWarnHandler(function(message, { id }, next) { + if (id !== 'ember-test-selectors.empty-tag-name') { + next(...arguments); + } +}); + loadInitializers(App, config.modulePrefix); + export default App; diff --git a/tests/dummy/app/pods/application/template.hbs b/tests/dummy/app/pods/application/template.hbs index 5c41c8e9..f261af13 100644 --- a/tests/dummy/app/pods/application/template.hbs +++ b/tests/dummy/app/pods/application/template.hbs @@ -1,4 +1,4 @@ -
+
The post has {{post.comments.length}} comments.
diff --git a/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/component.js b/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/component.js new file mode 100644 index 00000000..92c9692e --- /dev/null +++ b/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/component.js @@ -0,0 +1,46 @@ +import Component from '@ember/component'; +import { task } from 'ember-concurrency'; +import { inject as service } from '@ember/service'; +import { readOnly } from '@ember/object/computed'; +import { defineProperty } from '@ember/object'; + +export default Component.extend({ + + store: service(), + + didInsertElement() { + this._super(...arguments); + + this.get('loadPost').perform(); + this.setup(); + }, + + loadPost: task(function*() { + return yield this.get('store').findRecord('post', 2); + }), + + post: readOnly('loadPost.lastSuccessful.value'), + + setup() { + let tasks = { + // BEGIN-SNIPPET working-with-relationships-demo-2.js + reloadWithComments: task(function*() { + yield this.get('post').reloadWith('comments'); + }) + // END-SNIPPET + }; + + this.get('store').resetCache(); + // We do this to reset loadComments state + defineProperty(this, 'reloadWithComments', tasks.reloadWithComments); + this.notifyPropertyChange('reloadWithComments'); + }, + + actions: { + reset() { + this.setup(); + } + } + + +}); diff --git a/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/template.hbs b/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/template.hbs new file mode 100644 index 00000000..e266aa71 --- /dev/null +++ b/tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/template.hbs @@ -0,0 +1,39 @@ +{{#if post}} + + {{#docs-demo as |demo|}} + {{#demo.example name='working-with-relationships-demo-2.hbs'}} + ++ The post has {{post.comments.length}} comments. +
+ + {{else}} + + {{#ui-button + onClick=(perform reloadWithComments) + disabled=reloadWithComments.isRunning + data-test-id='reload-with-comments'}} + {{if reloadWithComments.isIdle 'Reload with comments' 'Loading...'}} + {{/ui-button}} + + {{/liquid-if}} + + {{/demo.example}} + + {{demo.snippet 'working-with-relationships-demo-2.js'}} + {{demo.snippet 'working-with-relationships-demo-2.hbs'}} + {{/docs-demo}} + +{{else}} + +Loading demo...
+ +{{/if}} diff --git a/tests/dummy/app/pods/docs/guides/working-with-relationships/template.md b/tests/dummy/app/pods/docs/guides/working-with-relationships/template.md index 358e0658..c5204793 100644 --- a/tests/dummy/app/pods/docs/guides/working-with-relationships/template.md +++ b/tests/dummy/app/pods/docs/guides/working-with-relationships/template.md @@ -2,9 +2,9 @@ Here are some patterns we recommend to make working with relationships more predictable. -## Explicitly load related data +## Explicitly loading related data -The `LoadableModel` mixin gives you a simple, expressive way to load related data from your models: +Storefront provides an expressive way to load related data from your models. To get started, you'll first need to add the `LoadableModel` mixin your models. ```js // models/post.js @@ -16,10 +16,26 @@ export default DS.Model.extend(LoadableModel, { }); ``` -Now you have an explicit, expressive API for asynchronously loading related data. +### Load related data + +Your models now have a `load` method that loads a relationship by name. Take a look at the following demo: {{docs/guides/working-with-relationships/demo-1}} +When called the first time, the `load` method will return a blocking promise that fulfills once the related data has been loaded into Ember Data's store. + +However, subsequent calls to `load` will instantly fulfill while a background reload refreshes the related data. Storefront tries to be as smart as possible and not return a blocking promises if it knows that it has already loaded the related data. + +This allows you to declare the data that you need data loaded for a specific component, while not having to worry about overcalling `load`. + +### Reload with related data + +Your models also have a way to side load related data by reloading themselves with a compound document. + +{{docs/guides/working-with-relationships/demo-2}} + +Similar to `load`, the first call to `reloadWith` will return a blocking promise. Subsequent calls will instantly fulfill while the model and relationship are reloaded in the background. + ## Avoiding async relationships **Why avoid async relationships?** diff --git a/tests/dummy/app/pods/playground/controller.js b/tests/dummy/app/pods/playground/controller.js new file mode 100644 index 00000000..58b7a5d6 --- /dev/null +++ b/tests/dummy/app/pods/playground/controller.js @@ -0,0 +1,28 @@ +import Controller from '@ember/controller'; +import { readOnly } from '@ember/object/computed'; +import { computed } from '@ember/object'; + +export default Controller.extend({ + post: readOnly('model'), + + comments: computed('post.comments', function() { + return this.get('post').hasMany('comments').value(); + }), + + actions: { + createServerComment() { + server.create('comment', { postId: this.get('post.id') }); + }, + + async loadComments() { + let returnValue = await this.get('post').load('comments'); + if (!this.get('returnValue')) { + this.set('returnValue', returnValue); + } + }, + + makeSiteSlow() { + server.timing = 5000; + } + } +}); diff --git a/tests/dummy/app/pods/playground/route.js b/tests/dummy/app/pods/playground/route.js new file mode 100644 index 00000000..4474b9d5 --- /dev/null +++ b/tests/dummy/app/pods/playground/route.js @@ -0,0 +1,9 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + + model() { + return this.store.loadRecord('post', 1); + } + +}); diff --git a/tests/dummy/app/pods/playground/template.hbs b/tests/dummy/app/pods/playground/template.hbs new file mode 100644 index 00000000..42e357ea --- /dev/null +++ b/tests/dummy/app/pods/playground/template.hbs @@ -0,0 +1,23 @@ +