├── .dependabot └── config.yml ├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .template-lintrc.js ├── .travis.yml ├── .watchmanconfig ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MODULE_REPORT.md ├── README.md ├── addon ├── -private │ ├── cache.js │ ├── coordinator.js │ ├── record-array-query.js │ ├── record-query.js │ └── utils │ │ └── get-key.js ├── adapters │ └── application.js ├── components │ └── assert-must-preload │ │ └── component.js ├── instance-initializers │ ├── inject-storefront.js │ └── mixin-storefront.js ├── mixins │ ├── fastboot-adapter.js │ ├── loadable-model.js │ ├── loadable-store.js │ ├── loadable.js │ └── snapshottable.js └── services │ └── storefront.js ├── app ├── .gitkeep ├── components │ └── assert-must-preload.js ├── instance-initializers │ ├── inject-storefront.js │ └── mixin-storefront.js ├── services │ └── storefront.js └── transitions.js ├── config ├── addon-docs.js ├── deploy.js ├── ember-try.js └── environment.js ├── ember-cli-build.js ├── fastboot-tests └── adapter-test.js ├── fastboot └── instance-initializers │ └── ember-data-storefront.js ├── index.js ├── package.json ├── server ├── .eslintrc.js ├── index.js └── mocks │ └── posts.js ├── testem.js ├── tests ├── acceptance │ ├── load-all-test.js │ └── load-relationship-test.js ├── dummy │ ├── app │ │ ├── adapters │ │ │ └── post.js │ │ ├── app.js │ │ ├── index.html │ │ ├── mixins │ │ │ └── itemizable.js │ │ ├── models │ │ │ ├── author.js │ │ │ ├── comment.js │ │ │ ├── homepage-item.js │ │ │ ├── post.js │ │ │ └── tag.js │ │ ├── pods │ │ │ ├── application │ │ │ │ └── template.hbs │ │ │ ├── components │ │ │ │ └── ui-button │ │ │ │ │ ├── component.js │ │ │ │ │ └── template.hbs │ │ │ ├── docs │ │ │ │ ├── guides │ │ │ │ │ ├── avoiding-errors │ │ │ │ │ │ └── template.md │ │ │ │ │ ├── common-data-issues │ │ │ │ │ │ └── template.md │ │ │ │ │ ├── data-fetching │ │ │ │ │ │ ├── demo-1 │ │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ │ ├── style.scss │ │ │ │ │ │ │ └── template.hbs │ │ │ │ │ │ ├── demo-2 │ │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ │ ├── style.scss │ │ │ │ │ │ │ └── template.hbs │ │ │ │ │ │ └── template.md │ │ │ │ │ ├── fastboot │ │ │ │ │ │ └── template.md │ │ │ │ │ └── working-with-relationships │ │ │ │ │ │ ├── demo-1 │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ └── template.hbs │ │ │ │ │ │ ├── demo-2 │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ └── template.hbs │ │ │ │ │ │ └── template.md │ │ │ │ ├── index │ │ │ │ │ └── template.md │ │ │ │ └── template.hbs │ │ │ ├── fastboot-tests │ │ │ │ ├── find-all-posts │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ ├── load-all-posts │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ └── load-record-post │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ ├── index │ │ │ │ ├── template.hbs │ │ │ │ └── x-intro │ │ │ │ │ └── template.md │ │ │ └── playground │ │ │ │ ├── controller.js │ │ │ │ ├── route.js │ │ │ │ └── template.hbs │ │ ├── resolver.js │ │ ├── router.js │ │ ├── serializers │ │ │ └── application.js │ │ ├── styles │ │ │ └── app.scss │ │ └── templates │ │ │ └── application.hbs │ ├── config │ │ ├── environment.js │ │ ├── optional-features.json │ │ └── targets.js │ ├── mirage │ │ ├── config.js │ │ ├── factories │ │ │ ├── comment.js │ │ │ └── post.js │ │ ├── scenarios │ │ │ └── default.js │ │ └── serializers │ │ │ ├── application.js │ │ │ └── post.js │ └── public │ │ ├── 404.html │ │ └── robots.txt ├── helpers │ ├── .gitkeep │ └── start-mirage.js ├── index.html ├── integration │ ├── -private │ │ └── cache-test.js │ ├── changing-data-render-test.js │ ├── components │ │ ├── assert-must-preload-test.js │ │ └── load-records-example-test.js │ ├── helpers │ │ └── mirage-server.js │ └── mixins │ │ ├── loadable-model │ │ ├── has-loaded-test.js │ │ ├── load-test.js │ │ └── sideload-test.js │ │ └── loadable-store │ │ ├── has-loaded-includes-for-record-test.js │ │ ├── load-record-test.js │ │ └── load-records-test.js ├── test-helper.js └── unit │ └── .gitkeep ├── vendor ├── .gitkeep ├── tachyons.css └── tachyons.min.css └── yarn.lock /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | update_configs: 4 | - package_manager: "javascript" 5 | directory: "/" 6 | update_schedule: "daily" 7 | automerged_updates: 8 | - match: 9 | dependency_type: "all" 10 | update_type: "in_range" 11 | ignored_updates: 12 | - match: 13 | dependency_name: "ember-cli" 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /bower.json.ember-try 20 | /package.json.ember-try 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | globals: { 5 | server: true, 6 | }, 7 | root: true, 8 | parser: 'babel-eslint', 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | legacyDecorators: true 14 | } 15 | }, 16 | plugins: [ 17 | 'ember' 18 | ], 19 | extends: [ 20 | 'eslint:recommended', 21 | 'plugin:ember/recommended' 22 | ], 23 | env: { 24 | browser: true 25 | }, 26 | rules: { 27 | 'ember/no-jquery': 'off', 28 | 'ember/no-new-mixins': 'off', 29 | 'no-useless-escape': 'off' 30 | }, 31 | overrides: [ 32 | // node files 33 | { 34 | files: [ 35 | '.eslintrc.js', 36 | '.template-lintrc.js', 37 | 'ember-cli-build.js', 38 | 'index.js', 39 | 'testem.js', 40 | 'blueprints/*/index.js', 41 | 'config/**/*.js', 42 | 'server/**/*.js', 43 | 'tests/dummy/config/**/*.js' 44 | ], 45 | excludedFiles: [ 46 | 'addon/**', 47 | 'addon-test-support/**', 48 | 'app/**', 49 | 'tests/dummy/app/**' 50 | ], 51 | parserOptions: { 52 | sourceType: 'script' 53 | }, 54 | env: { 55 | browser: false, 56 | node: true 57 | }, 58 | plugins: ['node'], 59 | rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, { 60 | 'node/no-unpublished-require': 'off' 61 | }) 62 | } 63 | ] 64 | }; 65 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | try: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 15 7 | strategy: 8 | matrix: 9 | ember-version: 10 | - 'ember-default' 11 | - 'ember-lts-3.12' 12 | - 'ember-lts-3.16' 13 | - 'ember-release' 14 | - 'ember-beta' 15 | - 'ember-canary' 16 | env: 17 | CI: true 18 | EMBER_TRY_SCENARIO: ${{ matrix.ember-version }} 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v2.1.5 23 | - run: yarn install 24 | - run: node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO --skip-cleanup 25 | continue-on-error: ${{ matrix.ember-version == 'ember-canary' }} 26 | 27 | docs: 28 | runs-on: ubuntu-latest 29 | timeout-minutes: 15 30 | needs: [] 31 | if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions/setup-node@v2.1.5 35 | - run: yarn install 36 | - uses: webfactory/ssh-agent@v0.1.1 37 | with: 38 | ssh-private-key: ${{ secrets.DEPLOY_KEY }} 39 | - run: git config --global user.email "github.actions@example.com" 40 | - run: git config --global user.name "Github Action" 41 | - run: node_modules/.bin/ember deploy production 42 | continue-on-error: true 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.env* 13 | /.pnp* 14 | /.sass-cache 15 | /connect.lock 16 | /coverage/ 17 | /libpeerconnection.log 18 | /npm-debug.log* 19 | /testem.log 20 | /yarn-error.log 21 | 22 | # ember-try 23 | /.node_modules.ember-try/ 24 | /bower.json.ember-try 25 | /package.json.ember-try 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # dependencies 6 | /bower_components/ 7 | 8 | # misc 9 | /.bowerrc 10 | /.editorconfig 11 | /.ember-cli 12 | /.env* 13 | /.eslintignore 14 | /.eslintrc.js 15 | /.git/ 16 | /.gitignore 17 | /.template-lintrc.js 18 | /.travis.yml 19 | /.watchmanconfig 20 | /bower.json 21 | /config/ember-try.js 22 | /CONTRIBUTING.md 23 | /ember-cli-build.js 24 | /testem.js 25 | /tests/ 26 | /yarn.lock 27 | .gitkeep 28 | 29 | # ember-try 30 | /.node_modules.ember-try/ 31 | /bower.json.ember-try 32 | /package.json.ember-try 33 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'octane' 5 | }; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | sudo: false 4 | dist: trusty 5 | node_js: 6 | - "10" 7 | 8 | addons: 9 | chrome: stable 10 | 11 | cache: 12 | yarn: true 13 | directories: 14 | - $HOME/.npm 15 | - $HOME/.cache # includes bowers cache 16 | - node_modules 17 | 18 | env: 19 | global: 20 | - JOBS=1 21 | 22 | before_install: 23 | - curl -o- -L https://yarnpkg.com/install.sh | bash 24 | - export PATH=$HOME/.yarn/bin:$PATH 25 | 26 | install: 27 | - yarn install 28 | 29 | before_script: 30 | - ember version 31 | - yarn list | grep ember-source 32 | - yarn list | grep ember-data 33 | 34 | notifications: 35 | email: false 36 | 37 | stages: 38 | - test 39 | - additional tests 40 | - fastboot tests 41 | - versioned tests 42 | - deploy 43 | 44 | script: 45 | - node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO --skip-cleanup 46 | 47 | jobs: 48 | fail_fast: true 49 | 50 | include: 51 | - stage: test 52 | env: NAME=test 53 | script: yarn test 54 | 55 | - stage: additional tests 56 | env: NAME=floating dependencies 57 | install: yarn install --no-lockfile --non-interactive 58 | script: yarn test 59 | 60 | - stage: fastboot tests 61 | node_js: "10" 62 | env: NAME=fastboot tests 63 | script: yarn test:fastboot 64 | 65 | - stage: versioned tests 66 | - env: EMBER_TRY_SCENARIO=ember-lts-3.12 67 | env: EMBER_TRY_SCENARIO=ember-lts-3.16 68 | - env: EMBER_TRY_SCENARIO=ember-release 69 | - env: EMBER_TRY_SCENARIO=ember-beta 70 | - env: EMBER_TRY_SCENARIO=ember-canary 71 | - env: EMBER_TRY_SCENARIO=ember-default 72 | 73 | - stage: deploy 74 | if: (branch = master OR tag IS present) AND type = push 75 | env: NAME=deploy 76 | script: node_modules/.bin/ember deploy production 77 | 78 | - stage: npm release 79 | install: skip 80 | before_script: skip 81 | script: skip 82 | deploy: 83 | provider: npm 84 | email: sam.selikoff@gmail.com 85 | api_key: 86 | secure: eZu7zw8mTnUsKkqF+7HVvaT12A/6DSeA+opUv/ZQeyfkkthwVY5LlZT4OdsLdqXt6YtwU4yOlBM740ApaHHeLz0vjOK04GgtnRVhX4byW6iRMMIdW1Unz6aYAiOzJqUXe4gWZ/PMFKtIaprmz0aV4kX97y05mkF0kiTymVqcStbwISTuEDWkt4Wxu/4BBScxl+48RRZaruLktdLDogYttUY989uhtfxYFeRts9RymbICNirPDlBWq4bu1AU+/Yrf36vnLRVtVp89ctZ+APRJddQukw/6eaJjCqnGwnTy51DXlV81Sr+cIIX5ille26wCVreDtIreQ8UxsdbQpAKt6Txyeg5EKRE7aIqC7NCKBG0+bf2PnOnNgbSx3V/7aa7r52U7fA7rNxye9pqc1s/hdM2RAzD8joos3crQPkB+CPntGRyhYWOhS59cLQPVdD2Wu4UG6zGMwOIoYLrA/hHxjKpnX/13qX+PaR7n37YiVGneXpkY/wtAytzjOExps6euRUJS5djebPpn6HkKyITMAt8V563apOLWK6NFBfmlVC5Quq6jsfyO22Q4P5vpL3Py1oVAbka06fH7cfJT9MpUFRzj9Y3gnulmq86mWFkK0ceJ1/JjaJ71+DklMhnDIIvi6krGk/BhXc+xZYboQ9FGzOezU6qeddPlzmlrhu+0opA= 87 | on: 88 | tags: true 89 | repo: embermap/ember-data-storefront 90 | 91 | allow_failures: 92 | - env: EMBER_TRY_SCENARIO=ember-canary 93 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | Releases (along with upgrade instructions) are documented on the Github [Releases](https://github.com/embermap/ember-data-storefront/releases) page. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | * `git clone ` 6 | * `cd ember-data-storefront` 7 | * `yarn install` 8 | 9 | ## Linting 10 | 11 | * `yarn lint:hbs` 12 | * `yarn lint:js` 13 | * `yarn lint:js --fix` 14 | 15 | ## Running tests 16 | 17 | * `ember test` – Runs the test suite on the current Ember version 18 | * `ember test --server` – Runs the test suite in "watch mode" 19 | * `ember try:each` – Runs the test suite against multiple Ember versions 20 | 21 | ## Running the dummy application 22 | 23 | * `ember serve` 24 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200). 25 | 26 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /MODULE_REPORT.md: -------------------------------------------------------------------------------- 1 | ## Module Report 2 | ### Unknown Global 3 | 4 | **Global**: `Ember.onerror` 5 | 6 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 26 7 | 8 | ```js 9 | this.store.resetCache(); 10 | this.server = startMirage(); 11 | onerror = Ember.onerror; 12 | adapterException = Ember.Test.adapter.exception 13 | loggerError = Ember.Logger.error; 14 | ``` 15 | 16 | ### Unknown Global 17 | 18 | **Global**: `Ember.Test` 19 | 20 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 27 21 | 22 | ```js 23 | this.server = startMirage(); 24 | onerror = Ember.onerror; 25 | adapterException = Ember.Test.adapter.exception 26 | loggerError = Ember.Logger.error; 27 | 28 | ``` 29 | 30 | ### Unknown Global 31 | 32 | **Global**: `Ember.Logger` 33 | 34 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 28 35 | 36 | ```js 37 | onerror = Ember.onerror; 38 | adapterException = Ember.Test.adapter.exception 39 | loggerError = Ember.Logger.error; 40 | 41 | // the next line doesn't work in 2.x due to an eslint rule 42 | ``` 43 | 44 | ### Unknown Global 45 | 46 | **Global**: `Ember.VERSION` 47 | 48 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 32 49 | 50 | ```js 51 | // the next line doesn't work in 2.x due to an eslint rule 52 | // eslint-disable-next-line 53 | [ this.major, this.minor ] = Ember.VERSION.split("."); 54 | }); 55 | 56 | ``` 57 | 58 | ### Unknown Global 59 | 60 | **Global**: `Ember.onerror` 61 | 62 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 36 63 | 64 | ```js 65 | 66 | hooks.afterEach(function() { 67 | Ember.onerror = onerror; 68 | Ember.Test.adapter.exception = adapterException; 69 | Ember.Logger.error = loggerError; 70 | ``` 71 | 72 | ### Unknown Global 73 | 74 | **Global**: `Ember.Test` 75 | 76 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 37 77 | 78 | ```js 79 | hooks.afterEach(function() { 80 | Ember.onerror = onerror; 81 | Ember.Test.adapter.exception = adapterException; 82 | Ember.Logger.error = loggerError; 83 | this.server.shutdown(); 84 | ``` 85 | 86 | ### Unknown Global 87 | 88 | **Global**: `Ember.Logger` 89 | 90 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 38 91 | 92 | ```js 93 | Ember.onerror = onerror; 94 | Ember.Test.adapter.exception = adapterException; 95 | Ember.Logger.error = loggerError; 96 | this.server.shutdown(); 97 | }); 98 | ``` 99 | 100 | ### Unknown Global 101 | 102 | **Global**: `Ember.Logger` 103 | 104 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 54 105 | 106 | ```js 107 | 108 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 109 | Ember.Logger.error = function() {}; 110 | Ember.Test.adapter.exception = assertError; 111 | } else { 112 | ``` 113 | 114 | ### Unknown Global 115 | 116 | **Global**: `Ember.Test` 117 | 118 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 55 119 | 120 | ```js 121 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 122 | Ember.Logger.error = function() {}; 123 | Ember.Test.adapter.exception = assertError; 124 | } else { 125 | Ember.onerror = assertError; 126 | ``` 127 | 128 | ### Unknown Global 129 | 130 | **Global**: `Ember.onerror` 131 | 132 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 57 133 | 134 | ```js 135 | Ember.Test.adapter.exception = assertError; 136 | } else { 137 | Ember.onerror = assertError; 138 | } 139 | 140 | ``` 141 | 142 | ### Unknown Global 143 | 144 | **Global**: `Ember.Logger` 145 | 146 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 77 147 | 148 | ```js 149 | 150 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 151 | Ember.Logger.error = function() {}; 152 | Ember.Test.adapter.exception = assertError; 153 | } else { 154 | ``` 155 | 156 | ### Unknown Global 157 | 158 | **Global**: `Ember.Test` 159 | 160 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 78 161 | 162 | ```js 163 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 164 | Ember.Logger.error = function() {}; 165 | Ember.Test.adapter.exception = assertError; 166 | } else { 167 | Ember.onerror = assertError; 168 | ``` 169 | 170 | ### Unknown Global 171 | 172 | **Global**: `Ember.onerror` 173 | 174 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 80 175 | 176 | ```js 177 | Ember.Test.adapter.exception = assertError; 178 | } else { 179 | Ember.onerror = assertError; 180 | } 181 | 182 | ``` 183 | 184 | ### Unknown Global 185 | 186 | **Global**: `Ember.Logger` 187 | 188 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 100 189 | 190 | ```js 191 | 192 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 193 | Ember.Logger.error = function() {}; 194 | Ember.Test.adapter.exception = assertError; 195 | } else { 196 | ``` 197 | 198 | ### Unknown Global 199 | 200 | **Global**: `Ember.Test` 201 | 202 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 101 203 | 204 | ```js 205 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 206 | Ember.Logger.error = function() {}; 207 | Ember.Test.adapter.exception = assertError; 208 | } else { 209 | Ember.onerror = assertError; 210 | ``` 211 | 212 | ### Unknown Global 213 | 214 | **Global**: `Ember.onerror` 215 | 216 | **Location**: `tests/integration/components/assert-must-preload-test.js` at line 103 217 | 218 | ```js 219 | Ember.Test.adapter.exception = assertError; 220 | } else { 221 | Ember.onerror = assertError; 222 | } 223 | 224 | ``` 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ember Data Storefront 2 | 3 | [![npm version](https://img.shields.io/npm/v/ember-data-storefront.svg?style=flat-square)](http://badge.fury.io/js/ember-data-storefront) 4 | [![Build Status](https://img.shields.io/travis/embermap/ember-data-storefront/master.svg?style=flat-square)](https://travis-ci.org/embermap/ember-data-storefront) 5 | 6 | A collection of APIs that address common data-loading issues. 7 | 8 | [View the docs here](https://embermap.github.io/ember-data-storefront/). 9 | 10 | ## About 11 | 12 | This library is developed and maintained by [EmberMap](https://embermap.com/). If your company is looking to see how it can get the most out of Ember, please [get in touch](mailto:info@embermap.com)! 13 | -------------------------------------------------------------------------------- /addon/-private/cache.js: -------------------------------------------------------------------------------- 1 | import { queryCacheKey, cacheKey } from './utils/get-key'; 2 | 3 | /* 4 | A cache for queries. 5 | */ 6 | export default class Cache { 7 | 8 | constructor() { 9 | this.store = {}; 10 | } 11 | 12 | get(...args) { 13 | let key = cacheKey(args); 14 | return this.store[key]; 15 | } 16 | 17 | put(query) { 18 | let key = queryCacheKey(query); 19 | this.store[key] = query; 20 | return query; 21 | } 22 | 23 | all() { 24 | return Object.keys(this.store).map(key => this.store[key]); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /addon/-private/coordinator.js: -------------------------------------------------------------------------------- 1 | import Cache from './cache'; 2 | import RecordQuery from './record-query'; 3 | import RecordArrayQuery from './record-array-query'; 4 | import { get } from '@ember/object'; 5 | 6 | // cleans options so that the resulting object only contains 7 | // data we want to send to the server as query params. 8 | let _cleanParams = function(options) { 9 | let clean = { ...{}, ...options }; 10 | delete clean.reload; 11 | delete clean.backgroundReload; 12 | return clean; 13 | } 14 | 15 | /* 16 | I know how to retrieve queries from the cache, and also assemble queries that 17 | are not in the cache but can be derived from them. 18 | */ 19 | export default class Coordinator { 20 | 21 | constructor(store) { 22 | this.store = store; 23 | this.recordCache = new Cache(); 24 | this.arrayCache = new Cache(); 25 | 26 | // A materialized view of loaded includes from the cache's queries. 27 | this.loadedIncludes = {}; 28 | } 29 | 30 | recordQueryFor(type, id, params) { 31 | let safeParams = _cleanParams(params); 32 | let query = this.recordCache.get(type, id, safeParams); 33 | 34 | if (!query) { 35 | query = this._assembleRecordQuery(type, id, safeParams); 36 | this._rememberRecordQuery(query); 37 | } 38 | 39 | return query; 40 | } 41 | 42 | recordArrayQueryFor(type, params) { 43 | let safeParams = _cleanParams(params); 44 | let query = this.arrayCache.get(type, safeParams); 45 | 46 | if (!query) { 47 | query = this._assembleRecordArrayQuery(type, safeParams); 48 | this._rememberRecordArrayQuery(query); 49 | } 50 | 51 | return query; 52 | } 53 | 54 | queryFor(...args) { 55 | return args.length === 3 ? 56 | this.recordQueryFor(...args) : 57 | this.recordArrayQueryFor(...args); 58 | } 59 | 60 | dump() { 61 | let records = this.recordCache.all(); 62 | let arrays = this.arrayCache.all(); 63 | 64 | return records.concat(arrays); 65 | } 66 | 67 | recordHasIncludes(type, id, includesString) { 68 | let query = this._assembleRecordQuery(type, id, { include: includesString }); 69 | let nonLoadedIncludes = this._nonLoadedIncludesForQuery(query); 70 | 71 | return nonLoadedIncludes.length === 0; 72 | } 73 | 74 | // Private 75 | 76 | _assembleRecordQuery(type, id, params) { 77 | let query = new RecordQuery(this.store, type, id, params); 78 | 79 | if (this._queryValueCanBeDerived(query)) { 80 | query.value = this.store.peekRecord(type, id); 81 | } 82 | 83 | return query; 84 | } 85 | 86 | _assembleRecordArrayQuery(type, params) { 87 | let query = new RecordArrayQuery(this.store, type, params); 88 | 89 | return query; 90 | } 91 | 92 | _queryValueCanBeDerived(query) { 93 | let queryKeys = Object.keys(query.params); 94 | if (queryKeys.length === 1 && queryKeys[0] === 'include') { 95 | let nonLoadedIncludes = this._nonLoadedIncludesForQuery(query); 96 | 97 | return nonLoadedIncludes.length === 0; 98 | } 99 | } 100 | 101 | _nonLoadedIncludesForQuery(query) { 102 | let loadedIncludes = get(this, `loadedIncludes.${query.type}.${query.id}`) || []; 103 | let includesString = query.params.include || ''; 104 | 105 | return includesString 106 | .split(',') 107 | .filter(include => !!include) 108 | .filter(include => { 109 | return !loadedIncludes.find(loadedInclude => { 110 | return loadedInclude.indexOf(include) === 0; 111 | }) 112 | }); 113 | } 114 | 115 | _rememberRecordQuery(query) { 116 | this.recordCache.put(query); 117 | this._updateLoadedIncludesWithQuery(query); 118 | } 119 | 120 | _rememberRecordArrayQuery(query) { 121 | this.arrayCache.put(query); 122 | } 123 | 124 | _updateLoadedIncludesWithQuery(query) { 125 | this.loadedIncludes[query.type] = this.loadedIncludes[query.type] || {}; 126 | this.loadedIncludes[query.type][query.id] = this.loadedIncludes[query.type][query.id] || []; 127 | 128 | let currentIncludes = this.loadedIncludes[query.type][query.id]; 129 | let nonLoadedIncludes = this._nonLoadedIncludesForQuery(query); 130 | let newLoadedIncludes = [...currentIncludes, ...nonLoadedIncludes]; 131 | 132 | this.loadedIncludes[query.type][query.id] = newLoadedIncludes; 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /addon/-private/record-array-query.js: -------------------------------------------------------------------------------- 1 | export default class RecordArrayQuery { 2 | 3 | constructor(store, type, params = {}) { 4 | this.store = store; 5 | this.type = type; 6 | this.params = params; 7 | 8 | this.value = null; 9 | } 10 | 11 | run() { 12 | let promise; 13 | 14 | if (this.value) { 15 | promise = this.value.update(); 16 | 17 | } else { 18 | promise = this.store.query(this.type, this.params) 19 | .then(records => { 20 | this.value = records; 21 | 22 | return records; 23 | }); 24 | } 25 | 26 | return promise; 27 | } 28 | 29 | trackIncludes() { 30 | let includes = this.params && this.params.include; 31 | let models = this.value; 32 | 33 | if (includes && models) { 34 | models 35 | .filter(model => model.trackLoadedIncludes) 36 | .forEach((model) => { 37 | model.trackLoadedIncludes(includes); 38 | }); 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /addon/-private/record-query.js: -------------------------------------------------------------------------------- 1 | export default class RecordQuery { 2 | 3 | constructor(store, type, id, params = {}) { 4 | this.store = store; 5 | this.type = type; 6 | this.id = id; 7 | this.params = params; 8 | 9 | // if we have no params, we can use the model from 10 | // the store if it exists, nice lil shortcut here. 11 | this.value = Object.keys(this.params).length === 0 ? 12 | this.store.peekRecord(type, id) : 13 | null; 14 | } 15 | 16 | run() { 17 | // if we're running a query in storefront we always want 18 | // a blocking promise, so we force reload true. 19 | let options = { ...{ reload: true }, ...this.params }; 20 | 21 | return this.store.findRecord(this.type, this.id, options) 22 | .then(record => { 23 | this.value = record; 24 | 25 | return record; 26 | }); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /addon/-private/utils/get-key.js: -------------------------------------------------------------------------------- 1 | let _serializeParams = function(params={}, prefix) { 2 | const query = Object.keys(params) 3 | .sort() 4 | .map((key) => { 5 | const value = params[key]; 6 | 7 | if (Array.isArray(params)) { 8 | key = `${prefix}[]`; 9 | } else if (params === Object(params)) { 10 | key = (prefix ? `${prefix}[${key}]` : key); 11 | } 12 | 13 | if (typeof value === 'object' && value !== null) { 14 | return _serializeParams(value, key); 15 | } else { 16 | return `${key}=${encodeURIComponent(value)}`; 17 | } 18 | }); 19 | 20 | return [].concat.apply([], query).join('&'); 21 | }; 22 | 23 | let serializeObject = function(params) { 24 | return _serializeParams(params); 25 | }; 26 | 27 | let queryCacheKey = function(query) { 28 | return cacheKey([query.type, query.id, query.params]); 29 | }; 30 | 31 | let cacheKey = function(args) { 32 | return args 33 | .map(part => typeof part === "object" ? serializeObject(part) : part) 34 | .filter(part => !!part) 35 | .join('::'); 36 | } 37 | 38 | let shoeboxize = function(key) { 39 | return key.replace(/&/g, '--'); // IDGAF 40 | } 41 | 42 | export { 43 | serializeObject, 44 | queryCacheKey, 45 | cacheKey, 46 | shoeboxize 47 | } 48 | -------------------------------------------------------------------------------- /addon/adapters/application.js: -------------------------------------------------------------------------------- 1 | import JSONAPIAdapter from '@ember-data/adapter/json-api'; 2 | 3 | export default JSONAPIAdapter.extend({ 4 | 5 | }); 6 | -------------------------------------------------------------------------------- /addon/components/assert-must-preload/component.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import Component from '@ember/component'; 3 | 4 | /** 5 | _This component relies on JSON:API, and assumes that your server supports JSON:API includes._ 6 | 7 | _{{assert-must-preload}} only works on models that have included the LoadableModel mixin._ 8 | 9 | Use this when authoring a component that requires a model to be passed in with 10 | certain relationships already loaded. 11 | 12 | For example, if you wanted to ensure the following template was never rendered without `post.comments` already loaded, you could add the call to `{{assert-must-preload}}`: 13 | 14 | ```hbs 15 | {{assert-must-preload post 'comments.author'}} 16 | 17 | {{!-- the rest of your template --}} 18 | {{#each post.comments as |comment|}} 19 | This comment was written by {{comment.author.name}} 20 | {{/each}} 21 | ``` 22 | 23 | If any developer ever tries to render this template without first loading the post's `comments.author`, they'll get a dev-time error. 24 | 25 | @class AssertMustPreload 26 | @public 27 | */ 28 | export default Component.extend({ 29 | tagName: '', 30 | 31 | didReceiveAttrs() { 32 | let [ model, ...includes ] = this.get('args'); 33 | let parentComponent = this.parentView; 34 | let parentName = parentComponent ? parentComponent._debugContainerKey : 'template'; 35 | let includesString = includes.join(','); 36 | 37 | assert( 38 | `You passed a ${model.constructor.modelName} model into an {{assert-must-preload}}, but that model is not using the Loadable mixin. [ember-data-storefront]`, 39 | model.hasLoaded 40 | ); 41 | 42 | assert( 43 | `You tried to render a ${parentName} that accesses relationships off of a ${model.constructor.modelName}, but that model didn't have all of its required relationships preloaded ('${includesString}'). Please make sure to preload the association. [ember-data-storefront]`, 44 | model.hasLoaded(includesString) 45 | ); 46 | 47 | return this._super(...arguments); 48 | } 49 | 50 | }).reopenClass({ 51 | 52 | positionalParams: 'args' 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /addon/instance-initializers/inject-storefront.js: -------------------------------------------------------------------------------- 1 | export function initialize(appInstance) { 2 | appInstance.inject('route', 'storefront', 'service:storefront'); 3 | appInstance.inject('controller', 'storefront', 'service:storefront'); 4 | } 5 | 6 | export default { 7 | name: 'inject-storefront', 8 | after: 'mixin-storefront', 9 | initialize 10 | }; 11 | -------------------------------------------------------------------------------- /addon/instance-initializers/mixin-storefront.js: -------------------------------------------------------------------------------- 1 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 2 | 3 | export function initialize(appInstance) { 4 | let store = appInstance.lookup('service:store'); 5 | store.reopen(LoadableStore); 6 | store.resetCache(); 7 | } 8 | 9 | export default { 10 | name: 'mixin-storefront', 11 | after: 'ember-data', 12 | initialize 13 | }; 14 | -------------------------------------------------------------------------------- /addon/mixins/fastboot-adapter.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-new-mixins */ 2 | 3 | import Mixin from '@ember/object/mixin'; 4 | import { inject as service } from '@ember/service'; 5 | import { resolve } from 'rsvp'; 6 | import { cacheKey, shoeboxize } from 'ember-data-storefront/-private/utils/get-key'; 7 | import { getOwner } from '@ember/application'; 8 | /** 9 | This mixin adds fastboot support to your data adapter. It provides no 10 | public API, it only needs to be mixed into your adapter. 11 | 12 | ```js 13 | // app/adpaters/application.js 14 | 15 | import JSONAPIAdapter from 'ember-data/adapters/json-api'; 16 | import FastbootAdapter from 'ember-data-storefront/mixins/fastboot-adapter'; 17 | 18 | export default JSONAPIAdapter.extend( 19 | FastbootAdapter, { 20 | 21 | // ... 22 | 23 | }); 24 | ``` 25 | 26 | @class FastbootAdapter 27 | @public 28 | */ 29 | export default Mixin.create({ 30 | fastboot: service(), 31 | storefront: service(), 32 | 33 | init() { 34 | this._super(...arguments); 35 | if (this.fastboot.isFastBoot) { 36 | this.set('storefront.fastbootShoeboxCreated', new Date()); 37 | } 38 | }, 39 | 40 | ajax(url, type, options = {}) { 41 | let cachedPayload = this._getStorefrontBoxedQuery(type, url, options.data); 42 | let maybeAddToShoebox = this._makeStorefrontQueryBoxer(type, url, options.data); 43 | 44 | return cachedPayload ? 45 | resolve(JSON.parse(cachedPayload)) : 46 | this._super(...arguments).then(maybeAddToShoebox); 47 | }, 48 | 49 | _makeStorefrontQueryBoxer(type, url, params) { 50 | let fastboot = this.get('fastboot'); 51 | let isFastboot = fastboot && fastboot.get('isFastBoot'); 52 | let cache = this.get('storefront.fastbootDataRequests'); 53 | 54 | return function(response) { 55 | if (isFastboot) { 56 | let key = shoeboxize(cacheKey([type, url.replace(/^.*\/\/[^\/]+/, ''), params])); 57 | cache[key] = JSON.stringify(response); 58 | } 59 | 60 | return response; 61 | } 62 | }, 63 | 64 | _getStorefrontBoxedQuery(type, url, params) { 65 | let payload; 66 | let fastboot = this.get('fastboot'); 67 | let isFastboot = fastboot && fastboot.get('isFastBoot'); 68 | let shoebox = fastboot && fastboot.get('shoebox'); 69 | let box = shoebox && shoebox.retrieve('ember-data-storefront'); 70 | 71 | const config = getOwner(this).resolveRegistration('config:environment'); 72 | const maxAgeMinutes = config.storefront ? config.storefront.maxAge : undefined; 73 | 74 | if (!isFastboot && box && box.queries && Object.keys(box.queries).length > 0) { 75 | const shouldUseShoebox = maxAgeMinutes === undefined || this.isDateValid(box.created, maxAgeMinutes); 76 | let key = shoeboxize(cacheKey([type, url.replace(/^.*\/\/[^\/]+/, ''), params])); 77 | 78 | if (shouldUseShoebox) { 79 | payload = box.queries[key]; 80 | } 81 | delete box.queries[key]; 82 | } 83 | 84 | return payload; 85 | }, 86 | 87 | isDateValid(createdString, maxAgeMinutes) { 88 | return (new Date() - new Date(createdString)) / 1000 / 60 < maxAgeMinutes; 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /addon/mixins/loadable-model.js: -------------------------------------------------------------------------------- 1 | import Mixin from '@ember/object/mixin'; 2 | import { deprecate } from '@ember/debug' 3 | import { assert } from '@ember/debug'; 4 | import { resolve } from 'rsvp'; 5 | import { isArray } from '@ember/array'; 6 | import { get } from '@ember/object'; 7 | import { camelize } from '@ember/string'; 8 | 9 | /** 10 | This mixin adds new data-loading methods to your Ember Data models. 11 | 12 | To use it, extend a model and mix it in: 13 | 14 | ```js 15 | // app/models/post.js 16 | import DS from 'ember-data'; 17 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 18 | 19 | export default DS.Model.extend(LoadableModel); 20 | ``` 21 | 22 | Once you understand how `LoadableModel` works We suggest adding it to every model in your app. You can do this by reopening `DS.Model` in `app.js.` and mixing it in: 23 | 24 | ```js 25 | // app/app.js 26 | import DS from 'ember-data'; 27 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 28 | 29 | DS.Model.reopen(LoadableModel); 30 | ``` 31 | 32 | @class LoadableModel 33 | @public 34 | */ 35 | export default Mixin.create({ 36 | 37 | init() { 38 | this._super(...arguments); 39 | this.set('_loadedReferences', {}); 40 | }, 41 | 42 | reloadWith(...args) { 43 | deprecate( 44 | 'reloadWith has been renamed to sideload. Please change all instances of reloadWith in your app to sideload. reloadWith will be removed in 1.0', 45 | false, 46 | { id: 'ember-data-storefront.reloadWith', until: '1.0.0' } 47 | ); 48 | 49 | return this.sideload(...args); 50 | }, 51 | 52 | /** 53 | `sideload` gives you an explicit way to asynchronously sideload related data. 54 | 55 | ```js 56 | post.sideload('comments'); 57 | ``` 58 | 59 | The above uses Storefront's `loadRecord` method to query your backend for the post along with its comments. 60 | 61 | You can also use JSON:API's dot notation to load additional related relationships. 62 | 63 | ```js 64 | post.sideload('comments.author'); 65 | ``` 66 | 67 | Every call to `sideload()` will return a promise. 68 | 69 | ```js 70 | post.sideload('comments').then((post) => console.log('loaded comments!')); 71 | ``` 72 | 73 | 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 `sideload()`. 74 | 75 | When relationship data has already been loaded, `sideload` will use a background refresh to update the relationship. To prevent sideload from making network requests for data that has already been loaded, use the `{ backgroundReload: false }` option. 76 | 77 | ```js 78 | post.sideload('comments', { backgroundReload: false }); 79 | ``` 80 | 81 | If you would like calls to `sideload` to always return a blocking promise, use the `{ reload: true }` option. 82 | 83 | ```js 84 | post.sideload('comments', { reload: true }) 85 | ``` 86 | 87 | 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. 88 | 89 | This method relies on JSON:API and assumes that your server supports JSON:API includes. 90 | 91 | @method sideload 92 | @param {String} includesString a JSON:API includes string representing the relationships to check 93 | @param {Object} options (optional) a hash of options 94 | @return {Promise} a promise resolving with the record 95 | @public 96 | */ 97 | sideload(...args) { 98 | let modelName = this.constructor.modelName; 99 | let possibleOptions = args[args.length - 1]; 100 | let options; 101 | 102 | if (typeof possibleOptions === 'string') { 103 | options = { 104 | include: args.join(',') 105 | }; 106 | } else { 107 | options = { 108 | ...possibleOptions, 109 | ...{ include: args.slice(0,-1).join(',') } 110 | }; 111 | } 112 | 113 | return this.get('store').loadRecord(modelName, this.get('id'), options); 114 | }, 115 | 116 | /** 117 | `load` gives you an explicit way to asynchronously load related data. 118 | 119 | ```js 120 | post.load('comments'); 121 | ``` 122 | 123 | The above uses Ember data's references API to load a post's comments from your backend. 124 | 125 | Every call to `load()` will return a promise. 126 | 127 | ```js 128 | post.load('comments').then((comments) => console.log('loaded comments as', comments)); 129 | ``` 130 | 131 | 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()`. 132 | 133 | When relationship data has already been loaded, `load` will use a background refresh to update the relationship. To prevent load from making network requests for data that has already been loaded, use the `{ backgroundReload: false }` option. 134 | 135 | ```js 136 | post.load('comments', { backgroundReload: false }); 137 | ``` 138 | 139 | If you would like calls to `load` to always return a blocking promise, use the `{ reload: true }` option. 140 | 141 | ```js 142 | post.load('comments', { reload: true }) 143 | ``` 144 | 145 | @method load 146 | @param {String} name the name of the relationship to load 147 | @return {Promise} a promise resolving with the related data 148 | @public 149 | */ 150 | load(name, options = { reload: false, backgroundReload: true }) { 151 | assert( 152 | `The #load method only works with a single relationship, if you need to load multiple relationships in one request please use the #sideload method [ember-data-storefront]`, 153 | !isArray(name) && !name.includes(',') && !name.includes('.') 154 | ); 155 | 156 | let reference = this._getReference(name); 157 | let value = reference.value(); 158 | let shouldBlock = !(value || this.hasLoaded(name)) || options.reload; 159 | let promise; 160 | 161 | if (shouldBlock) { 162 | let loadMethod = this._getLoadMethod(name, options); 163 | promise = reference[loadMethod].call(reference); 164 | } else { 165 | promise = resolve(value); 166 | if (options.backgroundReload) { 167 | reference.reload(); 168 | } 169 | } 170 | 171 | return promise.then(data => { 172 | // need to track that we loaded this relationship, since relying on the reference's 173 | // value existing is not enough 174 | this._loadedReferences[name] = true; 175 | return data; 176 | }); 177 | }, 178 | 179 | /** 180 | Returns 181 | 182 | @method _hasNamedRelationship 183 | @param {String} name The name of a relationship 184 | @return {Boolean} True if the current model has a relationship defined for the provided name 185 | @private 186 | */ 187 | _hasNamedRelationship(name) { 188 | return Boolean(get(this.constructor, `relationshipsByName`).get(name)); 189 | }, 190 | 191 | /** 192 | @method _getRelationshipInfo 193 | @private 194 | */ 195 | _getRelationshipInfo(name) { 196 | let relationshipInfo = get(this.constructor, `relationshipsByName`).get(name); 197 | 198 | assert( 199 | `You tried to load the relationship ${name} for a ${this.constructor.modelName}, but that relationship does not exist [ember-data-storefront]`, 200 | relationshipInfo 201 | ); 202 | 203 | return relationshipInfo; 204 | }, 205 | 206 | /** 207 | @method _getReference 208 | @private 209 | */ 210 | _getReference(name) { 211 | let relationshipInfo = this._getRelationshipInfo(name); 212 | let referenceMethod = relationshipInfo.kind; 213 | return this[referenceMethod](name); 214 | }, 215 | 216 | /** 217 | Given a relationship name this method will return the best way to load 218 | that relationship. 219 | 220 | @method _getLoadMethod 221 | @private 222 | */ 223 | _getLoadMethod(name, options) { 224 | let relationshipInfo = this._getRelationshipInfo(name); 225 | let reference = this._getReference(name); 226 | let hasLoaded = this._hasLoadedReference(name); 227 | let forceReload = options.reload; 228 | let isAsync; 229 | 230 | if (relationshipInfo.kind === 'hasMany') { 231 | isAsync = reference.hasManyRelationship.isAsync; 232 | } else if (relationshipInfo.kind === 'belongsTo') { 233 | isAsync = reference.belongsToRelationship.isAsync; 234 | } 235 | 236 | return !forceReload && isAsync && !hasLoaded ? 'load' : 'reload'; 237 | }, 238 | 239 | /** 240 | A list of models for a given relationship. It's always normalized to a list, 241 | even for belongsTo, null, or unloaded relationships. 242 | 243 | @method _getRelationshipModels 244 | @private 245 | */ 246 | _getRelationshipModels(name) { 247 | let reference = this._getReference(name); 248 | let info = this._getRelationshipInfo(name); 249 | let models; 250 | 251 | if (info.kind === 'hasMany') { 252 | models = reference.value() || []; 253 | } else if (info.kind === 'belongsTo') { 254 | models = reference.value() ? [ reference.value() ] : []; 255 | } 256 | 257 | return models; 258 | }, 259 | 260 | /** 261 | This is a private method because we may refactor it in the future to have 262 | a difference signature. However, this method is used by other 263 | storefront objects. So, it's really public, but don't use it in app code! 264 | 265 | @method trackLoadedIncludes 266 | @param {String} includes A full include path. Example: "author,comments.author,tags" 267 | @private 268 | */ 269 | trackLoadedIncludes(includes) { 270 | includes.split(",").forEach(path => this._trackLoadedIncludePath(path)); 271 | }, 272 | 273 | /** 274 | Tracks a single include path as being loaded. 275 | 276 | We verify that the current model actually has a named relationship defined 277 | for the first segment of the include path, because requests for polymorphic 278 | collections could return mixed sets of models that don't share all of 279 | the relationships that were requested via includes. 280 | 281 | @method _trackLoadedIncludePath 282 | @param {String} path A single include path. Example: "comments.author" 283 | @private 284 | */ 285 | _trackLoadedIncludePath(path) { 286 | let [firstInclude, ...rest] = path.split("."); 287 | let relationship = camelize(firstInclude); 288 | 289 | if (this._hasNamedRelationship(relationship)) { 290 | this._loadedReferences[relationship] = true; 291 | 292 | if (rest.length) { 293 | this._getRelationshipModels(relationship) 294 | .filter(model => model.trackLoadedIncludes) 295 | .forEach(model => model.trackLoadedIncludes(rest.join('.'))); 296 | } 297 | } 298 | }, 299 | 300 | /** 301 | This method can take an include string and see if the graph of objects 302 | in the store related to this model have all loaded each of the elements 303 | in that include string. 304 | 305 | @method _graphHasLoaded 306 | @param {String} includes A full include path. Example: "author,comments.author,tags" 307 | @return {Boolean} True if the includes have been loaded, false if not 308 | @private 309 | */ 310 | _graphHasLoaded(includes) { 311 | return includes 312 | .split(",") 313 | .every(path => this._graphHasLoadedPath(path)); 314 | }, 315 | 316 | /** 317 | Checks wether a single include path has been loaded. 318 | 319 | @method _graphHasLoadedPath 320 | @param {String} path A single include path. Example: "comments.author" 321 | @return {Boolean} True if the path has been loaded, false if not 322 | @private 323 | */ 324 | _graphHasLoadedPath(includePath) { 325 | let [firstInclude, ...rest] = includePath.split("."); 326 | let relationship = camelize(firstInclude); 327 | let reference = this._getReference(relationship); 328 | let hasLoaded = reference && this._hasLoadedReference(relationship); 329 | 330 | if (rest.length === 0) { 331 | return hasLoaded; 332 | 333 | } else { 334 | let models = this._getRelationshipModels(relationship); 335 | 336 | let childrenHaveLoaded = models.every(model => { 337 | return model.trackLoadedIncludes && model._graphHasLoaded(rest.join(".")); 338 | }); 339 | 340 | return hasLoaded && childrenHaveLoaded; 341 | } 342 | }, 343 | 344 | /** 345 | Checks if storefront has ever loaded this reference. 346 | 347 | @method _hasLoadedReference 348 | @param {String} name Reference or relationshipname name. 349 | @return {Boolean} True if storefront has loaded the reference. 350 | @private 351 | */ 352 | _hasLoadedReference(name) { 353 | return this._loadedReferences[name]; 354 | }, 355 | 356 | /** 357 | This method returns true if the provided includes string has been loaded and false if not. 358 | 359 | @method hasLoaded 360 | @param {String} includesString a JSON:API includes string representing the relationships to check 361 | @return {Boolean} true if the includes has been loaded, false if not 362 | @public 363 | */ 364 | hasLoaded(includesString) { 365 | let modelName = this.constructor.modelName; 366 | return this.get('store').hasLoadedIncludesForRecord(modelName, this.get('id'), includesString) || 367 | this._graphHasLoaded(includesString); 368 | } 369 | 370 | }); 371 | -------------------------------------------------------------------------------- /addon/mixins/loadable-store.js: -------------------------------------------------------------------------------- 1 | import Mixin from '@ember/object/mixin'; 2 | import { deprecate } from '@ember/debug' 3 | import { resolve } from 'rsvp'; 4 | import Coordinator from 'ember-data-storefront/-private/coordinator'; 5 | 6 | /** 7 | This mixin that adds new data-loading methods to Ember Data's store. 8 | 9 | It is automatically mixed into your application's store when you install the addon. 10 | 11 | @class LoadableStore 12 | @public 13 | */ 14 | export default Mixin.create({ 15 | 16 | init() { 17 | this._super(...arguments); 18 | 19 | this.resetCache(); 20 | }, 21 | 22 | /** 23 | `loadRecords` can be used in place of `store.query` to fetch a collection of records for the given type and options. 24 | 25 | ```diff 26 | this.get('store') 27 | - .query('post', { filter: { popular: true } }) 28 | + .loadRecords('post', { filter: { popular: true } }) 29 | .then(models => models); 30 | ``` 31 | 32 | `loadRecords` caches based on the query you provide, so each of the following examples would return a blocking promise the first time they are called, and instantly resolve from the cache thereafter. 33 | 34 | ```js 35 | // filters 36 | store.loadRecords('post', { filter: { popular: true }}); 37 | 38 | // pagination 39 | store.loadRecords('post', { page: { limit: 10, offset: 0 }}); 40 | 41 | // includes 42 | store.loadRecords('post', { include: 'comments' }); 43 | 44 | // force an already loaded set to reload (blocking promise) 45 | store.loadRecords('post', { reload: true }); 46 | ``` 47 | 48 | In most cases, `loadRecords` should be a drop-in replacement for `query` that eliminates bugs and improves your app's caching. 49 | 50 | @method loadRecords 51 | @param {String} type type of model to load 52 | @param {Object} options (optional) a hash of options 53 | @return {Promise} a promise resolving with the record array 54 | @public 55 | */ 56 | loadRecords(type, options={}) { 57 | let query = this.coordinator.recordArrayQueryFor(type, options); 58 | let shouldBlock = options.reload || !query.value; 59 | let shouldBackgroundReload = (options.backgroundReload !== undefined) ? options.backgroundReload : true; 60 | let promise; 61 | let fetcher; 62 | 63 | if (shouldBlock) { 64 | promise = query.run(); 65 | fetcher = promise; 66 | 67 | } else { 68 | promise = resolve(query.value); 69 | 70 | fetcher = shouldBackgroundReload ? query.run() : resolve(); 71 | } 72 | 73 | fetcher.then(() => query.trackIncludes()); 74 | 75 | return promise; 76 | }, 77 | 78 | loadAll(...args) { 79 | deprecate( 80 | 'loadAll has been renamed to loadRecords. Please change all instances of loadAll in your app to loadRecords. loadAll will be removed in 1.0.', 81 | false, 82 | { id: 'ember-data-storefront.loadAll', until: '1.0.0' } 83 | ); 84 | 85 | return this.loadRecords(...args); 86 | }, 87 | 88 | /** 89 | `loadRecord` can be used in place of `store.findRecord` to fetch a single record for the given type, id and options. 90 | 91 | ```diff 92 | this.get('store') 93 | - .findRecord('post', 1, { include: 'comments' }) 94 | + .loadRecord('post', 1, { include: 'comments' }) 95 | .then(post => post); 96 | ``` 97 | 98 | `loadRecord` caches based on the query you provide, so each of the following examples would return a blocking promise the first time they are called, and synchronously resolve from the cache thereafter. 99 | 100 | ```js 101 | // simple fetch 102 | this.get('store').loadRecord('post', 1); 103 | 104 | // includes 105 | this.get('store').loadRecord('post', 1, { include: 'comments' }); 106 | ``` 107 | 108 | This solves many common bugs where `findRecord` would return immediately, even if important `includes` had never been loaded. 109 | 110 | Similar to `store.findRecord`, you can force a query to reload using `reload: true`: 111 | 112 | ``` 113 | // force an already loaded set to reload (blocking promise) 114 | store.loadRecord('post', 1, { reload: true }); 115 | ``` 116 | 117 | In most cases, `loadRecord` should be a drop-in replacement for `findRecord` that eliminates bugs and improves your app's caching. 118 | 119 | @method loadRecord 120 | @param {String} type type of model to load 121 | @param {Number} id id of model to load 122 | @param {Object} options (optional) a hash of options 123 | @return {Promise} a promise resolving with the record array 124 | @public 125 | */ 126 | loadRecord(type, id, options={}) { 127 | let query = this.coordinator.recordQueryFor(type, id, options); 128 | let shouldBlock = options.reload || !query.value; 129 | let shouldBackgroundReload = (options.backgroundReload !== undefined) ? options.backgroundReload : true; 130 | let promise; 131 | 132 | if (shouldBlock) { 133 | promise = query.run(); 134 | 135 | } else { 136 | promise = resolve(query.value); 137 | 138 | if (shouldBackgroundReload) { 139 | query.run(); 140 | } 141 | } 142 | 143 | return promise; 144 | }, 145 | 146 | /** 147 | _This method relies on JSON:API, and assumes that your server supports JSON:API includes._ 148 | 149 | Lets you check whether you've ever loaded related data for a model. 150 | 151 | ```js 152 | this.get('store').hasLoadedIncludesForRecord('post', '1', 'comments.author'); 153 | ``` 154 | 155 | @method hasLoadedIncludesForRecord 156 | @param {String} type type of model to check 157 | @param {Number} id id of model to check 158 | @param {String} includesString a JSON:API includes string representing the relationships to check 159 | @return {Boolean} whether the includesString has been loaded 160 | @public 161 | */ 162 | hasLoadedIncludesForRecord(type, id, includesString) { 163 | return this.coordinator.recordHasIncludes(type, id, includesString); 164 | }, 165 | 166 | /** 167 | @method resetCache 168 | @private 169 | */ 170 | resetCache() { 171 | this.coordinator = new Coordinator(this); 172 | } 173 | 174 | }); 175 | -------------------------------------------------------------------------------- /addon/mixins/loadable.js: -------------------------------------------------------------------------------- 1 | import Mixin from '@ember/object/mixin'; 2 | import { deprecate } from '@ember/debug' 3 | import { on } from '@ember/object/evented'; 4 | import LoadableModel from './loadable-model'; 5 | 6 | export default Mixin.create(LoadableModel, { 7 | 8 | showDeprecations: on('init', function() { 9 | deprecate( 10 | 'The Loadable mixin has been renamed to LoadableMixin. Please change all instances of Loadable in your app to LoadableMixin. Loadable will be removed in 1.0.', 11 | false, 12 | { id: 'ember-data-storefront.loadable', until: '1.0.0' } 13 | ); 14 | }) 15 | 16 | }); 17 | -------------------------------------------------------------------------------- /addon/mixins/snapshottable.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import { isArray } from '@ember/array'; 3 | import Mixin from '@ember/object/mixin'; 4 | 5 | export default Mixin.create({ 6 | 7 | /* 8 | Graph for a post looks like 9 | 10 | { 11 | author: true, 12 | comments: { 13 | author: true 14 | } 15 | } 16 | 17 | Call `true` to stop at a leaf, pass an object to keep going. 18 | 19 | Snapshots look like 20 | { 21 | relationships: { 22 | comments: [ 23 | { 24 | model: MODEL, 25 | relationships: { 26 | author: { model: MODEL, relationships: {} } 27 | } 28 | }, ... 29 | ], { 30 | author: { model: MODEL, relationships: {} } 31 | } 32 | } 33 | } 34 | */ 35 | takeSnapshot(graph={}) { 36 | let snapshot = { model: this, relationships: {} }; 37 | 38 | Object.keys(graph).forEach(key => { 39 | let node = graph[key]; 40 | let relationship = this.get(key); 41 | 42 | if (isArray(relationship)) { 43 | snapshot.relationships[key] = relationship.map(model => ({ model, relationships: {} })); 44 | } else { 45 | snapshot.relationships[key] = { model: relationship, relationships: {} }; 46 | } 47 | 48 | // call all this recursively instead 49 | if (typeof node === 'object') { 50 | Object.keys(node).forEach(subkey => { 51 | let namedRelationshipMeta = snapshot.relationships[key]; 52 | if (namedRelationshipMeta) { 53 | if (isArray(namedRelationshipMeta)) { 54 | namedRelationshipMeta.forEach(relationshipSnapshot => { 55 | let nestedRelationship = relationshipSnapshot.model.get(subkey); 56 | 57 | if (isArray(nestedRelationship)) { 58 | relationshipSnapshot.relationships[subkey] = nestedRelationship.map(model => ({ model, relationships: {} })); 59 | } else { 60 | relationshipSnapshot.relationships[subkey] = { model: nestedRelationship, relationships: {} }; 61 | } 62 | 63 | // check the node (would be handled by recursive call) 64 | }); 65 | } else { 66 | // Deal with object case 67 | let nestedRelationship = namedRelationshipMeta.model.get(subkey); 68 | 69 | if (isArray(nestedRelationship)) { 70 | namedRelationshipMeta.relationships[subkey] = nestedRelationship.map(model => ({ model, relationships: {} })); 71 | } else { 72 | namedRelationshipMeta.relationships[subkey] = { model: nestedRelationship, relationships: {} }; 73 | } 74 | } 75 | } 76 | }); 77 | } 78 | }); 79 | 80 | return snapshot; 81 | }, 82 | 83 | /* 84 | Snapshots look like this: 85 | 86 | { 87 | model: this, 88 | relationships: { 89 | comments: [ 90 | { 91 | model: MODEL, 92 | relationships: { 93 | author: { model: MODEL, relationships: {} } 94 | } 95 | }, ... 96 | ], { 97 | author: { model: MODEL, relationships: {} } 98 | } 99 | } 100 | } 101 | 102 | TODO: For now, calling rollbackAttributes on every model we restore. Silly because 103 | the attributes are not coming from the snapshot. We should use this.serialize to 104 | store them in a data structure. 105 | */ 106 | restoreSnapshot(snapshot) { 107 | snapshot.model && snapshot.model.rollbackAttributes(); 108 | 109 | Object.keys(snapshot.relationships).forEach(key => { 110 | let relationshipSnapshot = snapshot.relationships[key]; 111 | if (isArray(relationshipSnapshot)) { 112 | this.set(key, relationshipSnapshot.map(meta => meta.model)); 113 | relationshipSnapshot.forEach(rSnapshot => { 114 | let model = rSnapshot.model; 115 | model.rollbackAttributes(); 116 | if (Object.keys(rSnapshot.relationships).length) { 117 | assert(`You're trying to restore a snapshot on a ${model._debugContainerKey} but that model isn't snapshottable. Be sure to include the Snapshottable mixin.`, model.restoreSnapshot !== undefined); 118 | model.restoreSnapshot(rSnapshot); 119 | } 120 | }); 121 | } else { 122 | let { model } = relationshipSnapshot; 123 | this.set(key, model); 124 | 125 | // Model could be null (reverting to null relationship). 126 | if (model) { 127 | model.rollbackAttributes(); 128 | } 129 | 130 | if (Object.keys(relationshipSnapshot.relationships).length) { 131 | assert(`You're trying to restore a snapshot on a ${model._debugContainerKey} but that model isn't snapshottable. Be sure to include the Snapshottable mixin.`, model.restoreSnapshot !== undefined); 132 | model.restoreSnapshot(relationshipSnapshot); 133 | } 134 | } 135 | }); 136 | } 137 | 138 | }); 139 | -------------------------------------------------------------------------------- /addon/services/storefront.js: -------------------------------------------------------------------------------- 1 | import Service, { inject as service } from '@ember/service'; 2 | import { deprecate } from '@ember/debug' 3 | 4 | // do not delete this service! it's being used to communicte cached payloads 5 | // between the client and the browser 6 | export default Service.extend({ 7 | store: service(), 8 | 9 | fastbootDataRequests: null, 10 | 11 | init() { 12 | this._super(...arguments); 13 | this.set('fastbootDataRequests', {}); 14 | }, 15 | 16 | findAll() { 17 | deprecate( 18 | 'The storefront service has been deprecated, please use store.loadAll instead. Will be removed in 1.0.', 19 | false, 20 | { id: 'ember-data-storefront.storefront-find-all', until: '1.0.0' } 21 | ); 22 | 23 | return this.get('store').loadAll(...arguments); 24 | }, 25 | 26 | loadAll() { 27 | deprecate( 28 | 'The storefront service has been deprecated, please use store.loadAll instead. Will be removed in 1.0.', 29 | false, 30 | { id: 'ember-data-storefront.storefront-load-all', until: '1.0.0' } 31 | ); 32 | 33 | return this.get('store').loadAll(...arguments); 34 | }, 35 | 36 | findRecord() { 37 | deprecate( 38 | 'The storefront service has been deprecated, please use store.loadRecord instead. Will be removed in 1.0.', 39 | false, 40 | { id: 'ember-data-storefront.storefront-find-record', until: '1.0.0' } 41 | ); 42 | 43 | return this.get('store').findRecord(...arguments); 44 | }, 45 | 46 | loadRecord() { 47 | deprecate( 48 | 'The storefront service has been deprecated, please use store.loadRecord instead. Will be removed in 1.0.', 49 | false, 50 | { id: 'ember-data-storefront.storefront-load-record', until: '1.0.0' } 51 | ); 52 | 53 | return this.get('store').findRecord(...arguments); 54 | }, 55 | 56 | hasLoadedIncludesForRecord() { 57 | deprecate( 58 | 'The storefront service has been deprecated, please use store.hasLoadedIncludesForRecord instead. Will be removed in 1.0.', 59 | false, 60 | { id: 'ember-data-storefront.storefront-has-loaded-includes-for-record', until: '1.0.0' } 61 | ); 62 | 63 | return this.get('store').hasLoadedIncludesForRecord(...arguments); 64 | }, 65 | 66 | resetCache() { 67 | deprecate( 68 | 'The storefront service has been deprecated, please use store.resetCache instead. Will be removed in 1.0.', 69 | false, 70 | { id: 'ember-data-storefront.storefront-reset-cache', until: '1.0.0' } 71 | ); 72 | 73 | return this.get('store').resetCache(...arguments); 74 | } 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embermap/ember-data-storefront/414d632d9928ec033bda84a986f0a45a57cebd5a/app/.gitkeep -------------------------------------------------------------------------------- /app/components/assert-must-preload.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data-storefront/components/assert-must-preload/component'; 2 | -------------------------------------------------------------------------------- /app/instance-initializers/inject-storefront.js: -------------------------------------------------------------------------------- 1 | export { default, initialize } from 'ember-data-storefront/instance-initializers/inject-storefront'; 2 | -------------------------------------------------------------------------------- /app/instance-initializers/mixin-storefront.js: -------------------------------------------------------------------------------- 1 | export { default, initialize } from 'ember-data-storefront/instance-initializers/mixin-storefront'; 2 | -------------------------------------------------------------------------------- /app/services/storefront.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data-storefront/services/storefront'; 2 | -------------------------------------------------------------------------------- /app/transitions.js: -------------------------------------------------------------------------------- 1 | export default function(){ 2 | // Add your transitions here, like: 3 | // this.transition( 4 | // this.fromRoute('people.index'), 5 | // this.toRoute('people.detail'), 6 | // this.use('toLeft'), 7 | // this.reverse('toRight') 8 | // ); 9 | } 10 | -------------------------------------------------------------------------------- /config/addon-docs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const AddonDocsConfig = require('ember-cli-addon-docs/lib/config'); 5 | 6 | module.exports = class extends AddonDocsConfig { 7 | /* 8 | Return a boolean indicating whether or not the current deploy should 9 | actually run. The `info` parameter contains details about the most recent 10 | git commit. Note that you can also access any configured environment 11 | variables via `process.ENV`. 12 | 13 | info.branch => the current branch or `null` if not on a branch 14 | info.sha => the current sha 15 | info.abbreviatedSha => the first 10 chars of the current sha 16 | info.tag => the tag for the current sha or `null` if none exists 17 | info.committer => the committer for the current sha 18 | info.committerDate => the commit date for the current sha 19 | info.author => the author for the current sha 20 | info.authorDate => the authored date for the current sha 21 | info.commitMessage => the commit message for the current sha 22 | 23 | Note that CI providers typically check out a specific commit hash rather 24 | than a named branch, so `info.branch` may not be available in CI builds. 25 | */ 26 | shouldDeploy(info) { 27 | /* 28 | For example, you might configure your CI builds to execute `ember deploy` 29 | at the end of each successful run, but you may only want to actually 30 | deploy builds on the `master` branch with the default ember-try scenario. 31 | To accomplish that on Travis, you could write: 32 | 33 | return process.env.TRAVIS_BRANCH === 'master' 34 | && process.env.EMBER_TRY_SCENARIO == 'ember-default'; 35 | */ 36 | return super.shouldDeploy(info); 37 | } 38 | 39 | /* 40 | Return a string indicating a subdirectory in the gh-pages branch you want 41 | to deploy to, or nothing to deploy to the root. This hook receives the same 42 | info object as `shouldDeploy` above. 43 | */ 44 | deployDirectory(info) { 45 | /* 46 | For example, to deploy a permalink-able copy of your docs site any time 47 | you tag a release, you could write: 48 | 49 | return info.tag ? `tags/${info.tag}` : 'master'; 50 | */ 51 | return super.deployDirectory(info); 52 | } 53 | 54 | /* 55 | By default, the folder returned by `deployDirectory()` above will be 56 | emptied out before a new revision of the docs application is written there. 57 | 58 | To retain certain files across deploys, return an array of file paths or 59 | globs, relative to the deploy directory, indicating files/directories that 60 | should not be removed before deploying. 61 | */ 62 | preservedPaths(info) { 63 | /* 64 | For example, if you had static JSON in your gh-pages branch powering 65 | something like a blog UI that you want to manage separately from your 66 | app deploys, you might write: 67 | 68 | return ['blog-posts/*.json', ...super.preservedPaths(info)]; 69 | */ 70 | return super.preservedPaths(info); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /config/deploy.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function(deployTarget) { 5 | let ENV = { 6 | build: {}, 7 | git: { 8 | repo: 'git@github.com:embermap/ember-data-storefront.git' 9 | } 10 | }; 11 | 12 | if (deployTarget === 'development') { 13 | ENV.build.environment = 'development'; 14 | // configure other plugins for development deploy target here 15 | } 16 | 17 | if (deployTarget === 'staging') { 18 | ENV.build.environment = 'production'; 19 | // configure other plugins for staging deploy target here 20 | } 21 | 22 | if (deployTarget === 'production') { 23 | ENV.build.environment = 'production'; 24 | // configure other plugins for production deploy target here 25 | } 26 | 27 | // Note: if you need to build some configuration asynchronously, you can return 28 | // a promise that resolves with the ENV object instead of returning the 29 | // ENV object synchronously. 30 | return ENV; 31 | }; 32 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const getChannelURL = require("ember-source-channel-url"); 4 | 5 | module.exports = function() { 6 | return Promise.all([ 7 | getChannelURL("release"), 8 | getChannelURL("beta"), 9 | getChannelURL("canary") 10 | ]).then(urls => { 11 | return { 12 | useYarn: true, 13 | scenarios: [ 14 | { 15 | name: "ember-lts-3.12", 16 | npm: { 17 | devDependencies: { 18 | "ember-source": "~3.12.0", 19 | "ember-data": "~3.12.0" 20 | }, 21 | resolutions: { 22 | "ember-data": "~3.12.0" 23 | } 24 | } 25 | }, 26 | { 27 | name: "ember-lts-3.16", 28 | npm: { 29 | devDependencies: { 30 | "ember-source": "~3.16.0", 31 | "ember-data": "~3.16.0" 32 | }, 33 | resolutions: { 34 | "ember-data": "~3.16.0" 35 | } 36 | } 37 | }, 38 | { 39 | name: "ember-release", 40 | npm: { 41 | devDependencies: { 42 | "ember-source": urls[0], 43 | "ember-data": "latest" 44 | } 45 | } 46 | }, 47 | { 48 | name: "ember-beta", 49 | npm: { 50 | devDependencies: { 51 | "ember-source": urls[1], 52 | "ember-data": "beta" 53 | } 54 | } 55 | }, 56 | { 57 | name: "ember-canary", 58 | npm: { 59 | devDependencies: { 60 | "ember-source": urls[2], 61 | "ember-data": "canary" 62 | } 63 | } 64 | }, 65 | { 66 | name: "ember-default", 67 | npm: { 68 | devDependencies: {} 69 | } 70 | } 71 | ] 72 | }; 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(/* environment, appConfig */) { 4 | return { }; 5 | }; 6 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function(defaults) { 6 | let app = new EmberAddon(defaults, { 7 | svgJar: { 8 | sourceDirs: [ 9 | 'public', 10 | 'tests/dummy/public' 11 | ] 12 | } 13 | }); 14 | 15 | /* 16 | This build file specifies the options for the dummy test app of this 17 | addon, located in `/tests/dummy` 18 | This build file does *not* influence how the addon or the app using it 19 | behave. You most likely want to be modifying `./index.js` or app's build file 20 | */ 21 | 22 | return app.toTree(); 23 | }; 24 | -------------------------------------------------------------------------------- /fastboot-tests/adapter-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const FastBoot = require('fastboot'); 4 | const { execFileSync } = require('child_process'); 5 | const { module: Qmodule, test } = require('qunit'); 6 | const jsdom = require("jsdom"); 7 | const { JSDOM } = jsdom; 8 | const postsRouter = require('../server/mocks/posts'); 9 | const express = require('express'); 10 | 11 | // build the application 12 | execFileSync('node', ['./node_modules/.bin/ember', 'build']); 13 | 14 | let visitOptions = { 15 | request: { headers: { host: 'localhost:4201' } } 16 | }; 17 | 18 | Qmodule('Fastboot', function(hooks) { 19 | let fastboot; 20 | let server; 21 | 22 | hooks.before(async function() { 23 | fastboot = new FastBoot({ 24 | distPath: 'dist', 25 | resilient: false 26 | }); 27 | 28 | let app = express(); 29 | postsRouter(app); 30 | server = app.listen(4201); 31 | }); 32 | 33 | hooks.after(async function() { 34 | server.close(); 35 | }); 36 | 37 | test('A fastboot rendered app should display loadRecords data fetched by the server', async function(assert) { 38 | let page = await fastboot.visit('/fastboot-tests/load-all-posts', visitOptions); 39 | let html = await page.html(); 40 | let dom = new JSDOM(html); 41 | let post1 = dom.window.document.querySelector('[data-test-id=post-title-1]'); 42 | 43 | assert.equal(post1.textContent.trim(), 'Hello from Ember CLI HTTP Mocks'); 44 | }); 45 | 46 | test('A fastboot rendered app should put storefront loadRecords queries in the shoebox', async function(assert) { 47 | let page = await fastboot.visit('/fastboot-tests/load-all-posts', visitOptions); 48 | let html = await page.html(); 49 | let dom = new JSDOM(html); 50 | 51 | let shoebox = dom.window.document 52 | .querySelector('#shoebox-ember-data-storefront') 53 | .textContent; 54 | 55 | let cache = JSON.parse(shoebox); 56 | let keys = Object.keys(cache.queries); 57 | 58 | assert.equal(keys.length, 1); 59 | assert.ok(cache.queries['GET::/posts::filter[popular]=true']); 60 | }); 61 | 62 | test('A fastboot rendered app should display loadRecord data fetched by the server', async function(assert) { 63 | let page = await fastboot.visit('/fastboot-tests/load-record-post/1', visitOptions); 64 | let html = await page.html(); 65 | let dom = new JSDOM(html); 66 | let post1 = dom.window.document.querySelector('[data-test-id=post-title]'); 67 | 68 | assert.equal(post1.textContent.trim(), 'Hello from Ember CLI HTTP Mocks'); 69 | }); 70 | 71 | test('A fastboot rendered app should put storefront loadRecords queries in the shoebox', async function(assert) { 72 | let page = await fastboot.visit('/fastboot-tests/load-record-post/1', visitOptions); 73 | let html = await page.html(); 74 | let dom = new JSDOM(html); 75 | 76 | let shoebox = dom.window.document 77 | .querySelector('#shoebox-ember-data-storefront') 78 | .textContent; 79 | 80 | let cache = JSON.parse(shoebox); 81 | let keys = Object.keys(cache.queries); 82 | 83 | assert.equal(keys.length, 1); 84 | assert.ok(cache.queries['GET::/posts/1']); 85 | }); 86 | 87 | test('A fastboot rendered app should display findAll data fetched by the server', async function(assert) { 88 | let page = await fastboot.visit('/fastboot-tests/find-all-posts', visitOptions); 89 | let html = await page.html(); 90 | let dom = new JSDOM(html); 91 | let post1 = dom.window.document.querySelector('[data-test-id=post-title-1]'); 92 | 93 | assert.equal(post1.textContent.trim(), 'Hello from Ember CLI HTTP Mocks'); 94 | }); 95 | 96 | test('A fastboot rendered app should put findAll queries in the shoebox', async function(assert) { 97 | let page = await fastboot.visit('/fastboot-tests/find-all-posts', visitOptions); 98 | let html = await page.html(); 99 | let dom = new JSDOM(html); 100 | 101 | let shoebox = dom.window.document 102 | .querySelector('#shoebox-ember-data-storefront') 103 | .textContent; 104 | 105 | let cache = JSON.parse(shoebox); 106 | let keys = Object.keys(cache.queries); 107 | 108 | assert.equal(keys.length, 1); 109 | assert.ok(cache.queries['GET::/posts::include=comments']); 110 | }); 111 | 112 | }); 113 | -------------------------------------------------------------------------------- /fastboot/instance-initializers/ember-data-storefront.js: -------------------------------------------------------------------------------- 1 | export function initialize(applicationInstance) { 2 | let shoebox = applicationInstance 3 | .lookup('service:fastboot') 4 | .get('shoebox'); 5 | 6 | let storefront = applicationInstance.lookup('service:storefront'); 7 | 8 | shoebox.put('ember-data-storefront', { 9 | get created() { 10 | return storefront.get('fastbootShoeboxCreated'); 11 | }, 12 | get queries() { 13 | return storefront.get('fastbootDataRequests'); 14 | } 15 | }); 16 | } 17 | 18 | export default { 19 | name: 'ember-data-storefront', 20 | initialize 21 | }; 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | name: 'ember-data-storefront', 5 | 6 | isDevelopingAddon() { 7 | return false; 8 | }, 9 | 10 | included() { 11 | let app; 12 | 13 | // If the addon has the _findHost() method (in ember-cli >= 2.7.0), we'll just 14 | // use that. 15 | if (typeof this._findHost === 'function') { 16 | app = this._findHost(); 17 | } else { 18 | // Otherwise, we'll use this implementation borrowed from the _findHost() 19 | // method in ember-cli. 20 | let current = this; 21 | do { 22 | app = current.app || app; 23 | } while (current.parent.parent && (current = current.parent)); 24 | } 25 | 26 | this.app = app; 27 | this.addonConfig = this.app.project.config(app.env)['ember-data-storefront'] || {}; 28 | } 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-data-storefront", 3 | "version": "0.18.2-ember4.0", 4 | "description": "Predictable data-loading APIs for Ember Data", 5 | "keywords": [ 6 | "ember-addon" 7 | ], 8 | "repository": "https://github.com/embermap/ember-data-storefront", 9 | "license": "MIT", 10 | "author": "", 11 | "directories": { 12 | "doc": "doc", 13 | "test": "tests" 14 | }, 15 | "contributors": [ 16 | "Sam Selikoff (https://embermap.com)", 17 | "Ryan Toronto (https://embermap.com)" 18 | ], 19 | "scripts": { 20 | "build": "ember build", 21 | "lint:hbs": "ember-template-lint .", 22 | "lint:js": "eslint .", 23 | "start": "ember serve", 24 | "test": "ember test", 25 | "test:all": "ember try:each", 26 | "test:node": "qunit node-tests/**/*-test.js", 27 | "test:fastboot": "qunit fastboot-tests" 28 | }, 29 | "dependencies": { 30 | "ember-cli-babel": "^7.5.0", 31 | "global": "^4.3.2" 32 | }, 33 | "devDependencies": { 34 | "@ember/jquery": "^1.1.0", 35 | "@ember/optional-features": "^1.0.0", 36 | "@fortawesome/ember-fontawesome": "^0.2.3", 37 | "@fortawesome/free-solid-svg-icons": "^5.15.2", 38 | "babel-eslint": "^10.0.3", 39 | "broccoli-asset-rev": "^3.0.0", 40 | "ember-ajax": "^5.0.0", 41 | "ember-auto-import": "^1.5.3", 42 | "ember-cli": "~3.16.0", 43 | "ember-cli-addon-docs": "0.10.0", 44 | "ember-cli-addon-docs-yuidoc": "^0.2.3", 45 | "ember-cli-dependency-checker": "^3.0.0", 46 | "ember-cli-deploy": "^1.0.2", 47 | "ember-cli-deploy-build": "^2.0.0", 48 | "ember-cli-deploy-git": "^1.3.0", 49 | "ember-cli-deploy-git-ci": "^1.0.1", 50 | "ember-cli-eslint": "^5.1.0", 51 | "ember-cli-fastboot": "^2.0.4", 52 | "ember-cli-htmlbars": "^4.0.1", 53 | "ember-cli-inject-live-reload": "^1.8.2", 54 | "ember-cli-mirage": "1.1.6", 55 | "ember-cli-qunit": "^4.3.2", 56 | "ember-cli-sass": "^10.0.0", 57 | "ember-cli-sri": "^2.1.1", 58 | "ember-cli-tachyons-shim": "^4.9.1", 59 | "ember-cli-uglify": "^3.0.0", 60 | "ember-component-css": "^0.7.4", 61 | "ember-concurrency": "^1.0.0", 62 | "ember-data": "~3.16.0", 63 | "ember-disable-prototype-extensions": "^1.1.3", 64 | "ember-export-application-global": "^2.0.0", 65 | "ember-fetch": "^8.0.0", 66 | "ember-load-initializers": "^2.0.0", 67 | "ember-maybe-import-regenerator": "^0.1.6", 68 | "ember-qunit-assert-helpers": "^0.2.1", 69 | "ember-resolver": "^7.0.0", 70 | "ember-router-scroll": "~1.3.2", 71 | "ember-source": "~3.16.0", 72 | "ember-source-channel-url": "^2.0.1", 73 | "ember-test-selectors": "3.0.0", 74 | "ember-try": "^1.4.0", 75 | "eslint-plugin-ember": "^7.1.0", 76 | "eslint-plugin-node": "^11.0.0", 77 | "express": "^4.16.4", 78 | "glob": "^7.1.4", 79 | "jsdom": "^16.0.0", 80 | "liquid-fire": "^0.31.0", 81 | "loader.js": "^4.7.0", 82 | "morgan": "^1.9.1", 83 | "qunit-dom": "^1.0.0", 84 | "sass": "^1.17.2", 85 | "strip-indent": "^3.0.0", 86 | "tachyons": "4.11.1" 87 | }, 88 | "engines": { 89 | "node": "10.* || >= 12.*" 90 | }, 91 | "ember-addon": { 92 | "configPath": "tests/dummy/config" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | // To use it create some files under `mocks/` 5 | // e.g. `server/mocks/ember-hamsters.js` 6 | // 7 | // module.exports = function(app) { 8 | // app.get('/ember-hamsters', function(req, res) { 9 | // res.send('hello'); 10 | // }); 11 | // }; 12 | 13 | module.exports = function(app) { 14 | const globSync = require('glob').sync; 15 | const mocks = globSync('./mocks/**/*.js', { cwd: __dirname }).map(require); 16 | 17 | // Log proxy requests 18 | const morgan = require('morgan'); 19 | app.use(morgan('dev')); 20 | 21 | mocks.forEach(route => route(app)); 22 | }; 23 | -------------------------------------------------------------------------------- /server/mocks/posts.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function(app) { 5 | const express = require('express'); 6 | let postsRouter = express.Router(); 7 | 8 | postsRouter.get('/', function(req, res) { 9 | res.send({ 10 | data: [ 11 | { 12 | type: 'posts', 13 | id: 1, 14 | attributes: { 15 | title: 'Hello from Ember CLI HTTP Mocks' 16 | } 17 | } 18 | ] 19 | }); 20 | }); 21 | 22 | postsRouter.get('/1', function(req, res) { 23 | res.send({ 24 | data: { 25 | type: 'posts', 26 | id: 1, 27 | attributes: { 28 | title: 'Hello from Ember CLI HTTP Mocks' 29 | } 30 | } 31 | }); 32 | }); 33 | 34 | app.use('/posts', postsRouter); 35 | }; 36 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: [ 7 | 'Chrome' 8 | ], 9 | launch_in_dev: [ 10 | 'Chrome' 11 | ], 12 | browser_start_timeout: 120, 13 | browser_args: { 14 | Chrome: { 15 | ci: [ 16 | // --no-sandbox is needed when running Chrome inside a container 17 | process.env.CI ? '--no-sandbox' : null, 18 | '--headless', 19 | '--disable-dev-shm-usage', 20 | '--disable-software-rasterizer', 21 | '--mute-audio', 22 | '--remote-debugging-port=0', 23 | '--window-size=1440,900' 24 | ].filter(Boolean) 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /tests/acceptance/load-all-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { visit, click, find, waitUntil } from "@ember/test-helpers"; 3 | import { setupApplicationTest } from 'ember-qunit'; 4 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 5 | 6 | function t(...args) { 7 | return args 8 | .map(arg => `[data-test-id="${arg}"]`) 9 | .join(' '); 10 | } 11 | 12 | async function domHasChanged(selector) { 13 | let previousUi = find(selector).textContent; 14 | return await waitUntil(() => { 15 | let currentUi = find(selector).textContent; 16 | 17 | return currentUi !== previousUi; 18 | }) 19 | } 20 | 21 | module('Acceptance | data fetching docs', function(hooks) { 22 | let server; 23 | 24 | setupApplicationTest(hooks); 25 | 26 | hooks.beforeEach(function() { 27 | server = startMirage(); 28 | }); 29 | 30 | hooks.afterEach(function() { 31 | server.shutdown(); 32 | }); 33 | 34 | test('data fetching guide', async function(assert) { 35 | // need our data fetching to be slow for these tests. 36 | server.timing = 1000; 37 | 38 | server.create('post', { id: '1', title: 'Post 1 title' }); 39 | server.create('post'); 40 | 41 | await visit('/docs/guides/data-fetching'); 42 | 43 | // Click post1-link, see loading, then see post1 44 | click(t('demo2', 'post1-link')); 45 | await domHasChanged(t('demo2', 'app-ui')); 46 | assert.dom(t('demo2', 'app-ui')).hasText('Loading /posts/1...'); 47 | 48 | await domHasChanged(t('demo2', 'app-ui')); 49 | assert.dom(t('demo2', 'app-ui')).hasText('Post 1 title'); 50 | 51 | // Click posts-link, see loading, then see list 52 | click(t('demo2', 'posts-link')); 53 | await domHasChanged(t('demo2', 'app-ui')); 54 | assert.dom(t('demo2', 'app-ui')).hasText('Loading /posts...'); 55 | 56 | await domHasChanged(t('demo2', 'app-ui')); 57 | assert.equal(find(t('demo2', 'app-ui')).querySelectorAll('li').length, 2); 58 | 59 | // Click posts1-link again, and only see post1 (no loading) 60 | click(t('demo2', 'post1-link')); 61 | await domHasChanged(t('demo2', 'app-ui')); 62 | assert.dom(t('demo2', 'app-ui')).hasText('Post 1 title'); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/acceptance/load-relationship-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { visit, click } from '@ember/test-helpers'; 3 | import { setupApplicationTest } from 'ember-qunit'; 4 | import startMirage from 'dummy/tests/helpers/start-mirage'; 5 | 6 | module('Acceptance | load relationship', function(hooks) { 7 | setupApplicationTest(hooks); 8 | startMirage(hooks); 9 | 10 | test('the load demo works', async function(assert) { 11 | await visit('/docs/guides/working-with-relationships'); 12 | 13 | await click('[data-test-id=load-comments]'); 14 | 15 | assert.dom('[data-test-id=load-comments-count]').hasText('The post has 3 comments.'); 16 | }); 17 | 18 | test('the sideload demo works', async function(assert) { 19 | await visit('/docs/guides/working-with-relationships'); 20 | 21 | await click('[data-test-id=sideload-comments]'); 22 | 23 | assert.dom('[data-test-id=sideload-comments-count]').hasText('The post has 5 comments.'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/dummy/app/adapters/post.js: -------------------------------------------------------------------------------- 1 | import JSONAPIAdapter from '@ember-data/adapter/json-api'; 2 | import FastbootAdapter from 'ember-data-storefront/mixins/fastboot-adapter'; 3 | 4 | export default JSONAPIAdapter.extend( 5 | FastbootAdapter, { 6 | 7 | // namespace: 'foo' 8 | 9 | }); 10 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Model from '@ember-data/model'; 2 | import Application from '@ember/application'; 3 | import Resolver from './resolver'; 4 | import loadInitializers from 'ember-load-initializers'; 5 | import config from './config/environment'; 6 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 7 | import { registerWarnHandler } from '@ember/debug'; 8 | 9 | Model.reopen(LoadableModel); 10 | 11 | const App = Application.extend({ 12 | modulePrefix: config.modulePrefix, 13 | podModulePrefix: config.podModulePrefix, 14 | Resolver 15 | }); 16 | 17 | // We'll ignore the empty tag name warning for test selectors since we have 18 | // empty tag names for pass through components. 19 | registerWarnHandler(function(message, { id }, next) { 20 | if (id !== 'ember-test-selectors.empty-tag-name') { 21 | next(...arguments); 22 | } 23 | }); 24 | 25 | loadInitializers(App, config.modulePrefix); 26 | 27 | 28 | export default App; 29 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ember Data Storefront 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy/app/mixins/itemizable.js: -------------------------------------------------------------------------------- 1 | import Mixin from '@ember/object/mixin'; 2 | 3 | // eslint-disable-next-line ember/no-new-mixins 4 | export default Mixin.create({}); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/models/author.js: -------------------------------------------------------------------------------- 1 | import Model, { attr, hasMany, belongsTo } from '@ember-data/model'; 2 | 3 | export default class AuthorModel extends Model { 4 | @attr('string') name; 5 | 6 | @hasMany() comments; 7 | @belongsTo() post; 8 | 9 | } 10 | -------------------------------------------------------------------------------- /tests/dummy/app/models/comment.js: -------------------------------------------------------------------------------- 1 | import Model, { attr, belongsTo } from '@ember-data/model'; 2 | 3 | export default class CommentModel extends Model { 4 | 5 | @attr('string') text; 6 | 7 | @belongsTo() post; 8 | @belongsTo() author; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /tests/dummy/app/models/homepage-item.js: -------------------------------------------------------------------------------- 1 | import Model, { belongsTo } from '@ember-data/model'; 2 | 3 | export default class HomepageItemModel extends Model { 4 | 5 | @belongsTo({ polymorphic: true }) itemizable; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/models/post.js: -------------------------------------------------------------------------------- 1 | import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; 2 | 3 | export default class PostModel extends Model { 4 | 5 | @attr('string') title; 6 | @attr('string') text; 7 | 8 | @belongsTo() author; 9 | @hasMany() comments; 10 | @hasMany() tags; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /tests/dummy/app/models/tag.js: -------------------------------------------------------------------------------- 1 | import Model, { hasMany } from '@ember-data/model'; 2 | 3 | export default class TagModel extends Model { 4 | 5 | @hasMany() posts; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/application/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{docs-header}} 3 | 4 | {{outlet}} 5 | 6 | {{docs-keyboard-shortcuts}} 7 |
-------------------------------------------------------------------------------- /tests/dummy/app/pods/components/ui-button/component.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | 3 | export default Component.extend({ 4 | tagName: '', 5 | supportsDataTestProperties: true, 6 | 7 | onClick() {}, 8 | }); 9 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/components/ui-button/template.hbs: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/avoiding-errors/template.md: -------------------------------------------------------------------------------- 1 | # Avoiding errors 2 | 3 | These patterns minimize the states in which your application can exist, helping you to avoid FOUC, undefined function calls, and other surprises. 4 | 5 | ## Ensuring data is loaded within templates 6 | 7 | 10 | 11 | You can use the `{{assert-must-preload}}` component to throw a dev-time warning if a template is rendered without all of its requisite data. This can help you avoid FOUC and the `n+1` query bug. 12 | 13 | ```hbs 14 | {{assert-must-preload post 'comments.author'}} 15 | 16 | {{#each post.comments as |comment|}} 17 | This comment was from {{comment.author.name}} 18 | {{/each}} 19 | ``` 20 | 21 | If this template is rendered with a `post` that has not loaded its `comments.author` relationship (either via `loadRecord` or the post's `#load` method), the developer will get a dev-time error. 22 | 23 | This template assertion is especially useful for reusable components with complex data requirements. 24 | 25 | Async relationships can also lead to surprises in actions by adding unnecessary states to your application. Read the section "Avoid async relationships" from the previous guide to learn more about their pitfalls, and how to enforce sync-only relationships in your apps. 26 | 27 | ## Ensuring data is loaded within JavaScript files 28 | 29 | 32 | 33 | You can use the `model#hasLoaded` method to throw a dev-time warning if a relationship is not yet loaded. This can help you avoid calling functions on undefined objects. 34 | 35 | ```js 36 | Component.extend({ 37 | post: null, // passed in post 38 | currentUser: service(), 39 | 40 | actions: { 41 | followAuthor() { 42 | Ember.assert( 43 | "The author isn't loaded", 44 | this.get('post').hasLoaded('author') 45 | ); 46 | 47 | this.get('currentUser').follow(this.get('post.author')); 48 | } 49 | } 50 | }); 51 | ``` 52 | 53 | If the `followAuthor` action is called without the post's author being loaded, the developer will see a dev-time error. 54 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/common-data-issues/template.md: -------------------------------------------------------------------------------- 1 | # Common data issues 2 | 3 | General information you might find helpful. 4 | 5 | ## Linking by model 6 | 7 | A common data issue in Ember occurs when using the `{{link-to}}` helper and passing a model. 8 | 9 | ```hbs 10 | {{link-to "View project" "projects.show" project}} 11 | ``` 12 | 13 | If a user clicks on this link, the Ember app will transition to the `projects.show` route, but that route's `model` hook will not run. This is by design - Ember treats the passed-in model as the resolved value of its `model()` hook, and skips that hook completely to avoid making any unnecessary network calls. 14 | 15 | This behavior is a common source of bugs, primarily because developers can pass models into `{{link-to}}` that don't match what would have been returned by a route's `model()` hook. A good example of this is if a model had been loaded on an index page, and was passed into a detail page, but that detail page was coded to expect a model that had been loaded with additional relationships. 16 | 17 | ```js 18 | // routes/projects/show.js 19 | model() { 20 | return this.store.findRecord('project', { include: 'assignees' }); 21 | } 22 | ``` 23 | 24 | If the model was loaded without the expected relationships, but then it was passed into `{{link-to}}`, that page would now render in an awkward or broken state. 25 | 26 | Our take on this problem is that _routes should be the only source of truth regarding what data is needed for routes to load_ (that is, what data should block a route from rendering), and they should declare those data needs in their `model()` hooks. 27 | 28 | Once routes declare those needs, all transitions into that route should go through those `model()` hooks, as this will guarantee that a route's data needs have been met. To do this, we recommend always passing an `id` to `{{link-to}}`, to force Ember routes to re-run their model hooks every time: 29 | 30 | ```hbs 31 | {{link-to "View project" "projects.show" project.id}} 32 | ``` 33 | 34 | 35 | 36 | Declared data needs also lend themselves to caching, so even if a `model()` hook is running for a second time, the tool responsible for the actual data-loading (e.g. Ember Data with Storefront) can respond synchronously with the cached data. (We feel that the tooling is the appropriate place to achieve caching, rather than developers thinking through all the various paths a user can take to get to a particular route.) 37 | 38 | If you're using Storefront and it's not loading data when entering certain routes like you expect, make sure that you're passing the model's `id` in as the second argument to the helper. -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-1/component.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { computed } from '@ember/object'; 3 | import { task } from 'ember-concurrency'; 4 | import { inject as service } from '@ember/service'; 5 | import { readOnly } from '@ember/object/computed'; 6 | import { A } from '@ember/array'; 7 | 8 | export default Component.extend({ 9 | 10 | store: service(), 11 | 12 | get serverPosts() { 13 | return window.server.db.dump().posts; 14 | }, 15 | 16 | clientPosts: computed(function() { 17 | return this.get('store').peekAll('post'); 18 | }), 19 | 20 | model: readOnly('visit.last.value'), 21 | activeRoute: readOnly('visitedRoutes.lastObject'), 22 | 23 | routes: computed(function() { 24 | return { 25 | '/posts': { 26 | // BEGIN-SNIPPET demo1-posts-route.js 27 | // route 28 | model() { 29 | return this.get('store').findAll('post'); 30 | } 31 | // END-SNIPPET 32 | }, 33 | '/posts/1': { 34 | // BEGIN-SNIPPET demo1-posts1-route.js 35 | // route 36 | model() { 37 | return this.get('store').findRecord('post', 1); 38 | } 39 | // END-SNIPPET 40 | } 41 | }; 42 | }), 43 | 44 | didInsertElement() { 45 | this._super(...arguments); 46 | this.reset(); 47 | }, 48 | 49 | visit: task(function * (routeName) { 50 | this.get('visitedRoutes').pushObject(routeName); 51 | 52 | return yield this.get(`routes.${routeName}.model`).call(this); 53 | }), 54 | 55 | reset() { 56 | this.get('store').unloadAll('post'); 57 | this.get('store').resetCache(); 58 | this.set('visitedRoutes', A([ '/' ])); 59 | }, 60 | 61 | actions: { 62 | visitRoute(routeName) { 63 | if (routeName !== this.get('activeRoute')) { 64 | this.get('visit').perform(routeName); 65 | } 66 | }, 67 | 68 | toggleExpand() { 69 | this.toggleProperty('isExpanded'); 70 | }, 71 | 72 | reset() { 73 | this.reset(); 74 | } 75 | } 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-1/style.scss: -------------------------------------------------------------------------------- 1 | .demo1 { 2 | background: #fafafa; 3 | display: flex; 4 | flex-wrap: wrap; 5 | margin: 40px 0; 6 | 7 | &__top-section, 8 | &__bottom-left-section, 9 | &__bottom-right-section { 10 | padding: 20px; 11 | } 12 | 13 | &__top-section { 14 | flex: 1 0 100%; 15 | } 16 | 17 | &__bottom-section { 18 | display: flex; 19 | width: 100%; 20 | background-image: linear-gradient(to right, #C8C8C8 64%, rgba(255, 255, 255, 0) 0%); 21 | background-position: top; 22 | background-size: 16px 1px; 23 | background-repeat: repeat-x; 24 | } 25 | 26 | &__bottom-left-section { 27 | width: 30%; 28 | background-image: linear-gradient(#C8C8C8 64%, rgba(255,255,255,0) 0%); 29 | background-position: right; 30 | background-size: 1px 16px; 31 | background-repeat: repeat-y; 32 | } 33 | &__bottom-right-section { 34 | width: 70%; 35 | } 36 | 37 | &__section-heading { 38 | color: #ABABAB; 39 | margin: 0; 40 | margin-bottom: 15px; 41 | font-size: 20px; 42 | } 43 | 44 | &__panel { 45 | background-color: white; 46 | padding: 10px 15px; 47 | box-shadow: 0 2px 4px 0 rgba(0,0,0,0.19); 48 | border-radius: 4px; 49 | margin-bottom: 20px; 50 | } 51 | 52 | &__panel-title { 53 | font-size: 18px; 54 | font-weight: bold; 55 | margin-top: 0; 56 | } 57 | &__panel-subtitle { 58 | font-size: 12px; 59 | } 60 | 61 | &__app-link { 62 | display: inline-block; 63 | margin-right: 15px; 64 | text-decoration: none; 65 | color: #7ED321; 66 | font-weight: bold; 67 | border-bottom: 1px solid transparent; 68 | 69 | &:hover { 70 | border-bottom-color: lighten(#7ED321, 35%); 71 | } 72 | } 73 | &__app-link-active { 74 | &, &:hover { 75 | border-bottom-color: #7ED321; 76 | } 77 | } 78 | 79 | &__app-url { 80 | font-size: 14px; 81 | color: #999; 82 | } 83 | 84 | &__table { 85 | thead td { 86 | color: #999; 87 | } 88 | } 89 | 90 | &__flex { 91 | display: flex; 92 | margin-bottom: 20px; 93 | } 94 | 95 | &__flex-items-center { 96 | align-items: center; 97 | } 98 | 99 | &__mla { 100 | margin-left: auto; 101 | } 102 | 103 | &__flex-w30 { 104 | flex: 0 0 30%; 105 | } 106 | 107 | &__flex-w70 { 108 | flex: 0 0 70%; 109 | padding-right: 15px; 110 | } 111 | &__flex-w50 { 112 | flex: 0 0 50%; 113 | padding-right: 15px; 114 | 115 | &:last-child { 116 | padding-right: 0; 117 | } 118 | } 119 | 120 | &__hr { 121 | border: none; 122 | border-top: 1px solid #eee; 123 | margin: 15px 0; 124 | } 125 | 126 | &__btn { 127 | border: none; 128 | font-weight: bold; 129 | padding: 5px 10px; 130 | background: #7ED321; 131 | box-shadow: 0 2px 0 0 #417505; 132 | border-radius: 5px; 133 | font-size: 14px; 134 | color: #FFFFFF; 135 | 136 | &:hover { 137 | cursor: pointer; 138 | background: darken(#7ED321, 5%); 139 | } 140 | } 141 | 142 | &__subtle { 143 | color: #999; 144 | } 145 | 146 | &__list { 147 | margin-top: 15px; 148 | padding-left: 20px; 149 | } 150 | &__expander { 151 | text-align: center; 152 | color: #999; 153 | font-size: 24px; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-1/template.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

App

5 |
6 | 7 |
8 |
9 | 10 |
11 |
12 | 14 | /posts 15 | 16 | 18 | /posts/1 19 | 20 | 21 | {{activeRoute}} 22 | 23 |
24 | 25 | {{#if visit.isRunning}} 26 |

{{fa-icon 'spinner' spin=true}} Loading {{activeRoute}}...

27 | {{else}} 28 | {{#if (eq activeRoute '/posts/1')}} 29 |

30 | {{!-- BEGIN-SNIPPET demo1-posts1-template.hbs --}} 31 | {{! template }} 32 | {{model.title}} 33 | {{!-- END-SNIPPET --}} 34 |

35 | {{else if (eq activeRoute '/posts')}} 36 |
    37 | {{!-- BEGIN-SNIPPET demo1-posts-template.hbs --}} 38 | {{! template }} 39 | {{#each model as |post|}} 40 |
  • {{post.title}}
  • 41 | {{/each}} 42 | {{!-- END-SNIPPET --}} 43 |
44 | {{else}} 45 |

Visit a route.

46 | {{/if}} 47 | {{/if}} 48 | 49 |
50 | 51 | 60 |
61 | 62 | 63 | {{#if isExpanded}} 64 |
65 |
66 |

Server

67 |
68 |

Resources

69 |

POSTS

70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | {{#each serverPosts as |post|}} 79 | 80 | 81 | 82 | 83 | {{/each}} 84 | 85 |
idtitle
{{post.id}}{{post.title}}
86 |
87 |
88 | 89 |
90 |

Client

91 | 92 |
93 |
94 |
95 |

Store

96 |

97 | POSTS ({{clientPosts.length}}) 98 |

99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | {{#each clientPosts as |post|}} 108 | 109 | 110 | 111 | 112 | {{/each}} 113 | 114 |
idtitle
{{post.id}}{{post.title}}
115 |
116 |
117 |
118 |
119 |

History

120 |
    121 | {{#each visitedRoutes as |route|}} 122 |
  • {{route}}
  • 123 | {{/each}} 124 |
125 |
126 |
127 |
128 | 129 |
130 |

Routes

131 |

/posts

132 |
133 | {{docs-snippet name="demo1-posts-route.js"}} 134 | {{docs-snippet name="demo1-posts-template.hbs"}} 135 |
136 | 137 |

/posts/1

138 |
139 | {{docs-snippet name="demo1-posts1-route.js"}} 140 | {{docs-snippet name="demo1-posts1-template.hbs"}} 141 |
142 |
143 | 144 |
145 |
146 | {{/if}} 147 |
148 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-2/component.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { computed } from '@ember/object'; 3 | import { task } from 'ember-concurrency'; 4 | import { inject as service } from '@ember/service'; 5 | import { readOnly } from '@ember/object/computed'; 6 | import { A } from '@ember/array'; 7 | 8 | export default Component.extend({ 9 | 10 | store: service(), 11 | 12 | get serverPosts() { 13 | return window.server.db.dump().posts; 14 | }, 15 | 16 | clientPosts: computed(function() { 17 | return this.get('store').peekAll('post'); 18 | }), 19 | 20 | model: readOnly('visit.last.value'), 21 | activeRoute: readOnly('visitedRoutes.lastObject'), 22 | 23 | routes: computed(function() { 24 | return { 25 | '/posts': { 26 | // BEGIN-SNIPPET demo2-posts-route.js 27 | // route 28 | model() { 29 | return this.get('store').loadRecords('post'); 30 | } 31 | // END-SNIPPET 32 | }, 33 | '/posts/1': { 34 | // BEGIN-SNIPPET demo2-posts1-route.js 35 | // route 36 | model() { 37 | return this.get('store').loadRecord('post', 1); 38 | } 39 | // END-SNIPPET 40 | } 41 | }; 42 | }), 43 | 44 | didInsertElement() { 45 | this._super(...arguments); 46 | this.reset(); 47 | }, 48 | 49 | visit: task(function * (routeName) { 50 | this.get('visitedRoutes').pushObject(routeName); 51 | 52 | return yield this.get(`routes.${routeName}.model`).call(this); 53 | }), 54 | 55 | reset() { 56 | this.get('store').unloadAll('post'); 57 | this.get('store').resetCache(); 58 | this.set('visitedRoutes', A([ '/' ])); 59 | }, 60 | 61 | actions: { 62 | visitRoute(routeName) { 63 | if (routeName !== this.get('activeRoute')) { 64 | this.get('visit').perform(routeName); 65 | } 66 | }, 67 | 68 | toggleExpand() { 69 | this.toggleProperty('isExpanded'); 70 | }, 71 | 72 | reset() { 73 | this.reset(); 74 | } 75 | } 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-2/style.scss: -------------------------------------------------------------------------------- 1 | .docs-snippet { 2 | max-width: 100%; 3 | overflow: scroll; 4 | } 5 | 6 | .demo2 { 7 | background: #fafafa; 8 | display: flex; 9 | flex-wrap: wrap; 10 | margin: 40px 0; 11 | 12 | &__top-section, 13 | &__bottom-left-section, 14 | &__bottom-right-section { 15 | padding: 20px; 16 | } 17 | 18 | &__top-section { 19 | flex: 1 0 100%; 20 | } 21 | 22 | &__bottom-section { 23 | width: 100%; 24 | display: flex; 25 | background-image: linear-gradient(to right, #C8C8C8 64%, rgba(255, 255, 255, 0) 0%); 26 | background-position: top; 27 | background-size: 16px 1px; 28 | background-repeat: repeat-x; 29 | } 30 | 31 | &__bottom-left-section { 32 | width: 30%; 33 | background-image: linear-gradient(#C8C8C8 64%, rgba(255,255,255,0) 0%); 34 | background-position: right; 35 | background-size: 1px 16px; 36 | background-repeat: repeat-y; 37 | } 38 | &__bottom-right-section { 39 | width: 70%; 40 | } 41 | 42 | &__section-heading { 43 | color: #ABABAB; 44 | margin: 0; 45 | margin-bottom: 15px; 46 | font-size: 20px; 47 | } 48 | 49 | &__panel { 50 | background-color: white; 51 | padding: 10px 15px; 52 | box-shadow: 0 2px 4px 0 rgba(0,0,0,0.19); 53 | border-radius: 4px; 54 | margin-bottom: 20px; 55 | } 56 | 57 | &__panel-title { 58 | font-size: 18px; 59 | font-weight: bold; 60 | margin-top: 0; 61 | } 62 | &__panel-subtitle { 63 | font-size: 12px; 64 | } 65 | 66 | &__app-link { 67 | display: inline-block; 68 | margin-right: 15px; 69 | text-decoration: none; 70 | color: #7ED321; 71 | font-weight: bold; 72 | border-bottom: 1px solid transparent; 73 | 74 | &:hover { 75 | border-bottom-color: lighten(#7ED321, 35%); 76 | } 77 | } 78 | &__app-link-active { 79 | &, &:hover { 80 | border-bottom-color: #7ED321; 81 | } 82 | } 83 | 84 | &__app-url { 85 | font-size: 14px; 86 | color: #999; 87 | } 88 | 89 | &__table { 90 | thead td { 91 | color: #999; 92 | } 93 | } 94 | 95 | &__flex { 96 | display: flex; 97 | margin-bottom: 20px; 98 | } 99 | 100 | &__flex-items-center { 101 | align-items: center; 102 | } 103 | 104 | &__mla { 105 | margin-left: auto; 106 | } 107 | 108 | &__flex-w30 { 109 | flex: 0 0 30%; 110 | } 111 | 112 | &__flex-w70 { 113 | flex: 0 0 70%; 114 | padding-right: 15px; 115 | } 116 | &__flex-w50 { 117 | flex: 0 0 50%; 118 | padding-right: 15px; 119 | 120 | &:last-child { 121 | padding-right: 0; 122 | } 123 | } 124 | 125 | &__hr { 126 | border: none; 127 | border-top: 1px solid #eee; 128 | margin: 15px 0; 129 | } 130 | 131 | &__btn { 132 | border: none; 133 | font-weight: bold; 134 | padding: 5px 10px; 135 | background: #7ED321; 136 | box-shadow: 0 2px 0 0 #417505; 137 | border-radius: 5px; 138 | font-size: 14px; 139 | color: #FFFFFF; 140 | 141 | &:hover { 142 | cursor: pointer; 143 | background: darken(#7ED321, 5%); 144 | } 145 | } 146 | 147 | &__subtle { 148 | color: #999; 149 | } 150 | 151 | &__list { 152 | padding-left: 20px; 153 | } 154 | &__expander { 155 | text-align: center; 156 | color: #999; 157 | font-size: 24px; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/demo-2/template.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

App

5 |
6 | 7 |
8 |
9 | 10 |
11 |
12 | 15 | /posts 16 | 17 | 20 | /posts/1 21 | 22 | 23 | {{activeRoute}} 24 | 25 |
26 | 27 |
28 | {{#if visit.isRunning}} 29 |

{{fa-icon 'spinner' spin=true}} Loading {{activeRoute}}...

30 | {{else}} 31 | {{#if (eq activeRoute '/posts/1')}} 32 |

33 | {{!-- BEGIN-SNIPPET demo2-posts1-template.hbs --}} 34 | {{! template }} 35 | {{model.title}} 36 | {{!-- END-SNIPPET --}} 37 |

38 | {{else if (eq activeRoute '/posts')}} 39 |
    40 | {{!-- BEGIN-SNIPPET demo2-posts-template.hbs --}} 41 | {{! template }} 42 | {{#each model as |post|}} 43 |
  • {{post.title}}
  • 44 | {{/each}} 45 | {{!-- END-SNIPPET --}} 46 |
47 | {{else}} 48 |

Visit a route.

49 | {{/if}} 50 | {{/if}} 51 |
52 | 53 |
54 | 55 | 64 |
65 | 66 | 67 | {{#if isExpanded}} 68 |
69 |
70 |

Server

71 |
72 |

Resources

73 |

POSTS

74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | {{#each serverPosts as |post|}} 83 | 84 | 85 | 86 | 87 | {{/each}} 88 | 89 |
idtitle
{{post.id}}{{post.title}}
90 |
91 |
92 | 93 |
94 |

Client

95 | 96 |
97 |
98 |
99 |

Store

100 |

101 | POSTS ({{clientPosts.length}}) 102 |

103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | {{#each clientPosts as |post|}} 112 | 113 | 114 | 115 | 116 | {{/each}} 117 | 118 |
idtitle
{{post.id}}{{post.title}}
119 |
120 |
121 |
122 |
123 |

History

124 |
    125 | {{#each visitedRoutes as |route|}} 126 |
  • {{route}}
  • 127 | {{/each}} 128 |
129 |
130 |
131 |
132 | 133 |
134 |

Routes

135 |

/posts

136 |
137 | {{docs-snippet name="demo2-posts-route.js"}} 138 | {{docs-snippet name="demo2-posts-template.hbs"}} 139 |
140 | 141 |

/posts/1

142 |
143 | {{docs-snippet name="demo2-posts1-route.js"}} 144 | {{docs-snippet name="demo2-posts1-template.hbs"}} 145 |
146 |
147 | 148 |
149 |
150 | {{/if}} 151 |
152 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/data-fetching/template.md: -------------------------------------------------------------------------------- 1 | # Data fetching 2 | 3 | Follow these patterns to improve your application's data-fetching story. 4 | 5 | ## Query-aware caching 6 | 7 | Storefront adds some `load` methods to Ember Data's `store` that can be used in place of `findAll`, `query`, and `findRecord`. They're built directly on top of Ember Data, but are more intelligent about caching. Another way to say this is that Storefront's `load` methods are query-aware. 8 | 9 | --- 10 | 11 | Ember Data's `store.findAll` method is used to fetch a collection from your backend. Typically this method will return a blocking promise while the network request is in progress; however, if Ember Data has any local records in its store when `findAll` is called, it will resolve the promise immediately and return everything in its local store, and then trigger a network request in the background. This can lead to unexpected rendering behavior. 12 | 13 | In the following example, compare how the app behaves when you visit the routes in a different order: 14 | 15 | - First, visit `/posts`, then visit `/posts/1`. 16 | - When you're done, reset the app. 17 | - This time, visit `/posts/1` first and `/posts` second. 18 | 19 | {{docs/guides/data-fetching/demo-1}} 20 | 21 | If you followed the steps above, what you'll notice is that under the second scenario, the `/posts` route was actually rendered in two states. First, it showed `post:1` in the list, and then after about a second the app re-rendered and the other two posts appeared. 22 | 23 | This is because the `/posts/1` route had already loaded the `post:1` record into Ember Data's store. By the time you visited the `/posts` index route, the promise from the store's `findAll` method resolved immediately with that `post:1` record, and then triggered a background reload of the entire `posts` collection. (Click the {{fa-icon 'angle-down'}} above to expand the demo for more details about what's happening as you navigate through the app.) 24 | 25 | Ember Data's `findAll` method accepts a `reload` option that we can use to force the promise to block, but then we'd lose the benefits of caching. (Note how after visiting `/posts` for the first time, it's fast on all subsequent visits.) 26 | 27 | Storefront's `loadRecords` was designed to avoid re-rendering problems like this. Let's make a change to our routes: we'll replace `store.findAll` with `store.loadRecords`: 28 | 29 | ```diff 30 | model() { 31 | - return this.get('store').findAll('post'); 32 | + return this.get('store').loadRecords('post'); 33 | } 34 | 35 | model() { 36 | - return this.get('store').findRecord('post', 1); 37 | + return this.get('store').loadRecord('post', 1); 38 | } 39 | ``` 40 | 41 | Now let's take a look at our app (be sure to click reset first): 42 | 43 | {{docs/guides/data-fetching/demo-2}} 44 | 45 | Notice that the behavior for the second scenario has changed. If we visit `/posts/1` first, and then click on `/posts`, we get a blocking promise. Storefront knows you haven't loaded all the `post` models yet, so instead of resolving instantly with the one post you happen to have in the store, it issues a network request for all posts and returns a blocking promise. In this way, you avoid the index route being in a state that you as the developer didn't intend. 46 | 47 | Storefront accomplishes this by tracking each query and its results individually. If you had previously been using `findAll` as a way of rendering all models in Ember Data's store, regardless of how they were loaded, then you should be aware that `loadRecords` is not a drop-in replacement for `findAll`. 48 | 49 | To correctly replace all calls to `findAll` with `loadRecords` you'll need to also use `store.peekAll`: 50 | 51 | 52 | ```diff 53 | async model() { 54 | - return this.get('store').findAll('post'); 55 | + await this.get('store').loadRecords('post'); 56 | + return this.get('store').peekAll('post'); 57 | } 58 | ``` 59 | 60 | Returning `peekAll` will ensure that the route is aware of any new posts created or loaded since its model hook ran. This matches the behavior of `findAll`, which is to load all posts and then return a live binding to all posts in Ember Data's store. 61 | 62 | Philosophically, Storefront's position is that routes (and other data-loading parts of your application) should be as declarative as possible. If you return `findAll('post')` from your route, you are declaring that this route needs a list of all posts in order to render. If your application has never made that network request, then it should block until it has made it for the first time. On subsequent visits, the page will render instantly using the cached value, as you can see by clicking around in the demo above. 63 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/fastboot/template.md: -------------------------------------------------------------------------------- 1 | # Fastboot support 2 | 3 | A fastboot rendered application will make data requests on the server in order to generate its HTML. Once the application is delivered to the browser it will re-render itself and make those same data requests that were just made on the server. 4 | 5 | This is problematic because the application will now make unnecessary network requests, incorrectly render loading templates, and display flashes of changing content. 6 | 7 | In order to avoid these problems you can use the `FastbootAdapter` mixin. 8 | 9 | ## Applying the mixin 10 | 11 | 15 | 16 | The mixin should be applied to your application adapter. 17 | 18 | ```js 19 | // app/adpaters/application.js 20 | 21 | import JSONAPIAdapter from 'ember-data/adapters/json-api'; 22 | import FastbootAdapter from 'ember-data-storefront/mixins/fastboot-adapter'; 23 | 24 | export default JSONAPIAdapter.extend( 25 | FastbootAdapter, { 26 | 27 | // ... 28 | 29 | }); 30 | ``` 31 | 32 | That's it! Once mixed-in it will prevent your application from making unnecessary data fetches. 33 | 34 | ## Under the hood 35 | 36 | The `FastbootAdapter` works by storing the results of AJAX requests into fastboot's shoebox. When the application is rendered on the client any data request that exists in the shoebox will be used and no network request will be made. 37 | 38 | The adapter will delete queries from the shoebox as soon as they are used. This ensures that if your application ever tries to re-make a network request in the future, it will not be served a cached version. 39 | 40 | Fastboot rendered pages need to be generated quickly, since they are rendered on the server in an HTTP request-resposne cycle. Because of this, they tend to not make many network requests. This means that a few seconds after the browser re-renders a fastboot page, the query cache should be empty since the client rendered application will have re-fetched all of the fastboot data. 41 | 42 | ## Configure an expiration date 43 | 44 | In certain cases, like the browser restoring its session, a cached version of the index.html page may be served, which contains shoebox data from the initial, cached request. The adapter is not aware that this data is stale, and outdated information is displayed to the user. To prevent this you can configure a duration after which data will no longer be served from the shoebox. 45 | 46 | If the `maxAge` is not set, by default data will always be served from the shoebox. 47 | 48 | ``` 49 | // config/environment.js 50 | ENV = { 51 | storefront: { 52 | maxAge: 10 // shoebox expires 10 minutes after the fastboot server has rendered the page 53 | } 54 | } 55 | ``` 56 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/working-with-relationships/demo-1/component.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { task } from 'ember-concurrency'; 3 | import { inject as service } from '@ember/service'; 4 | import { readOnly } from '@ember/object/computed'; 5 | import { defineProperty } from '@ember/object'; 6 | 7 | export default Component.extend({ 8 | 9 | store: service(), 10 | 11 | didInsertElement() { 12 | this._super(...arguments); 13 | 14 | this.get('loadPost').perform(); 15 | this.setup(); 16 | }, 17 | 18 | loadPost: task(function*() { 19 | return yield this.get('store').findRecord('post', 1); 20 | }), 21 | 22 | post: readOnly('loadPost.lastSuccessful.value'), 23 | 24 | setup() { 25 | let tasks = { 26 | // BEGIN-SNIPPET working-with-relationships-demo-1.js 27 | loadComments: task(function*() { 28 | yield this.get('post').load('comments'); 29 | }) 30 | // END-SNIPPET 31 | }; 32 | 33 | this.get('store').resetCache(); 34 | // We do this to reset loadComments state 35 | defineProperty(this, 'loadComments', tasks.loadComments); 36 | this.notifyPropertyChange('loadComments'); 37 | }, 38 | 39 | actions: { 40 | reset() { 41 | this.setup(); 42 | } 43 | } 44 | 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/working-with-relationships/demo-1/template.hbs: -------------------------------------------------------------------------------- 1 | {{#if post}} 2 | 3 | {{#docs-demo as |demo|}} 4 | {{#demo.example name='working-with-relationships-demo-1.hbs'}} 5 | 6 |
7 | {{#ui-button style='small' onClick=(action 'reset')}} 8 | Reset 9 | {{/ui-button}} 10 |
11 | 12 | {{#liquid-if loadComments.lastSuccessful}} 13 | 14 |

15 | The post has {{post.comments.length}} comments. 16 |

17 | 18 | {{else}} 19 | 20 | {{#ui-button 21 | onClick=(perform loadComments) 22 | disabled=loadComments.isRunning 23 | data-test-id='load-comments'}} 24 | {{if loadComments.isIdle 'Load comments' 'Loading...'}} 25 | {{/ui-button}} 26 | 27 | {{/liquid-if}} 28 | 29 | {{/demo.example}} 30 | 31 | {{demo.snippet 'working-with-relationships-demo-1.js'}} 32 | {{demo.snippet 'working-with-relationships-demo-1.hbs'}} 33 | {{/docs-demo}} 34 | 35 | {{else}} 36 | 37 |

Loading demo...

38 | 39 | {{/if}} 40 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/component.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { task } from 'ember-concurrency'; 3 | import { inject as service } from '@ember/service'; 4 | import { readOnly } from '@ember/object/computed'; 5 | import { defineProperty } from '@ember/object'; 6 | 7 | export default Component.extend({ 8 | 9 | store: service(), 10 | 11 | didInsertElement() { 12 | this._super(...arguments); 13 | 14 | this.get('loadPost').perform(); 15 | this.setup(); 16 | }, 17 | 18 | loadPost: task(function*() { 19 | return yield this.get('store').findRecord('post', 2); 20 | }), 21 | 22 | post: readOnly('loadPost.lastSuccessful.value'), 23 | 24 | setup() { 25 | let tasks = { 26 | // BEGIN-SNIPPET working-with-relationships-demo-2.js 27 | sideloadComments: task(function*() { 28 | yield this.get('post').sideload('comments'); 29 | }) 30 | // END-SNIPPET 31 | }; 32 | 33 | this.get('store').resetCache(); 34 | // We do this to reset loadComments state 35 | defineProperty(this, 'sideloadComments', tasks.sideloadComments); 36 | this.notifyPropertyChange('sideloadComments'); 37 | }, 38 | 39 | actions: { 40 | reset() { 41 | this.setup(); 42 | } 43 | } 44 | 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/working-with-relationships/demo-2/template.hbs: -------------------------------------------------------------------------------- 1 | {{#if post}} 2 | 3 | {{#docs-demo as |demo|}} 4 | {{#demo.example name='working-with-relationships-demo-2.hbs'}} 5 | 6 |
7 | {{#ui-button style='small' onClick=(action 'reset')}} 8 | Reset 9 | {{/ui-button}} 10 |
11 | 12 | {{#liquid-if sideloadComments.lastSuccessful}} 13 | 14 |

15 | The post has {{post.comments.length}} comments. 16 |

17 | 18 | {{else}} 19 | 20 | {{#ui-button 21 | onClick=(perform sideloadComments) 22 | disabled=sideloadComments.isRunning 23 | data-test-id='sideload-comments'}} 24 | {{if sideloadComments.isIdle 'Reload with comments' 'Loading...'}} 25 | {{/ui-button}} 26 | 27 | {{/liquid-if}} 28 | 29 | {{/demo.example}} 30 | 31 | {{demo.snippet 'working-with-relationships-demo-2.js'}} 32 | {{demo.snippet 'working-with-relationships-demo-2.hbs'}} 33 | {{/docs-demo}} 34 | 35 | {{else}} 36 | 37 |

Loading demo...

38 | 39 | {{/if}} 40 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/guides/working-with-relationships/template.md: -------------------------------------------------------------------------------- 1 | # Working with relationships 2 | 3 | Here are some patterns we recommend to make working with relationships more predictable. 4 | 5 | ## Explicitly loading related data 6 | 7 | Storefront provides an expressive way to load related data from your models. To get started, you'll first need to add the `LoadableModel` mixin to your models. 8 | 9 | ```js 10 | // models/post.js 11 | import DS from 'ember-data'; 12 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 13 | 14 | export default DS.Model.extend(LoadableModel, { 15 | comments: DS.hasMany() 16 | }); 17 | ``` 18 | 19 | ### Load related data 20 | 21 | Your models now have a `load` method that loads a relationship by name. Take a look at the following demo: 22 | 23 | {{docs/guides/working-with-relationships/demo-1}} 24 | 25 | 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. 26 | 27 | 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. 28 | 29 | This allows you to declare the data that you need data loaded for a specific component, while not having to worry about overcalling `load`. 30 | 31 | ### Reload with related data 32 | 33 | Your models also have a way to side load related data by reloading themselves with a compound document. 34 | 35 | {{docs/guides/working-with-relationships/demo-2}} 36 | 37 | Similar to `load`, the first call to `sideload` will return a blocking promise. Subsequent calls will instantly fulfill while the model and relationship are reloaded in the background. 38 | 39 | ## Avoiding async relationships 40 | 41 | **Why avoid async relationships?** 42 | 43 | 46 | 47 | We're of the opinion that many data-loading issues can be solved by avoiding async relationships. 48 | 49 | Async relationships overload `model.get` to do both remote data fetching and local data access. Since `#get` is typically used everywhere for local data access, we find this mixing of concerns confusing, and instead opt to avoid async relationships altogether. 50 | 51 | Sync relationships maintain the expected behavior of `#get` – returning local data – and in combination with our `Loadable` mixin, remote data fetching becomes explicit and local data access stays predictable. 52 | 53 | **How to enforce sync-only relationships** 54 | 55 | Follow the instructions and add the `force-sync-relationships` rule from our custom ESLint plugin to your app: [https://github.com/embermap/eslint-plugin-ember-data-sync-relationships](https://github.com/embermap/eslint-plugin-ember-data-sync-relationships) 56 | 57 | You'll see the linting errors in your editor and console, and if you set the rule to `error`, your test suite will fail if any relationships are async. 58 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/index/template.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Welcome to Storefront. Your life with Ember Data is about to get a whole lot better! 4 | 5 | ## Installation 6 | 7 | To install Storefront, run 8 | 9 | ```sh 10 | ember install ember-data-storefront 11 | ``` 12 | 13 | Once installed you'll be able to use the mixins in your app. See the LoadableModel and LoadableStore pages in the docs. 14 | 15 | ## Benefits 16 | 17 | ### Better data fetching 18 | 19 | Ever gotten bit by `store.findAll` resolving synchronously with the 1 record that happens to be loaded? Do you keep using `reload: true` as an escape hatch? Are you coding around whether a record has been "fully loaded" for a detail page? 20 | 21 | Storefront's Query API has your back. 22 | 23 | {{docs-link 'Learn more' 'docs.guides.data-fetching'}}. 24 | 25 | ### Predictable relationships 26 | 27 | Async relationships got you down? Check out our `Loadable` mixin and keep all your data-fetching logic explicit. 28 | 29 | {{docs-link 'Learn more' 'docs.guides.working-with-relationships'}}. 30 | 31 | ### Fewer rendering bugs 32 | 33 | Do certain paths through your application lead to templates being rendered with missing data? Tired of hitting the `n + 1` query bug? By declaring your routes' and templates' data needs, your app will become more robust. 34 | 35 | {{docs-link 'Learn more' 'docs.guides.avoiding-errors'}}. 36 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/docs/template.hbs: -------------------------------------------------------------------------------- 1 | {{#docs-viewer as |viewer|}} 2 | 3 | {{#viewer.nav as |nav|}} 4 | {{nav.section 'Introduction'}} 5 | {{nav.item 'Overview' 'docs.index'}} 6 | 7 | {{nav.section 'Guides'}} 8 | {{nav.item 'Data fetching' 'docs.guides.data-fetching'}} 9 | {{nav.item 'Working with relationships' 'docs.guides.working-with-relationships'}} 10 | {{nav.item 'Avoiding errors' 'docs.guides.avoiding-errors'}} 11 | {{nav.item 'Fastboot support' 'docs.guides.fastboot'}} 12 | {{nav.item 'Common data issues' 'docs.guides.common-data-issues'}} 13 | {{/viewer.nav}} 14 | 15 | {{#viewer.main}} 16 | {{outlet}} 17 | {{/viewer.main}} 18 | 19 | {{/docs-viewer}} 20 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/find-all-posts/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | model: function() { 5 | return this.store.findAll('post', { include: 'comments' }); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/find-all-posts/template.hbs: -------------------------------------------------------------------------------- 1 |

Fastboot tests

2 | 3 |

4 | This page was rendered by fastboot, which made an AJAX 5 | request to get the posts. This request should be in the 6 | shoebox. 7 |

8 | 9 | {{#each model as |post|}} 10 |
11 | {{post.title}} 12 |
13 | {{/each}} 14 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/load-all-posts/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | model: function() { 5 | return this.store.loadRecords('post', { filter: { popular: true }}); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/load-all-posts/template.hbs: -------------------------------------------------------------------------------- 1 |

Fastboot tests

2 | 3 |

4 | This page was rendered by fastboot, which made an AJAX 5 | request to get the posts. This request should be in the 6 | shoebox. 7 |

8 | 9 | {{#each model as |post|}} 10 |
11 | {{post.title}} 12 |
13 | {{/each}} 14 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/load-record-post/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | model: function() { 5 | return this.store.loadRecord('post', 1); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/fastboot-tests/load-record-post/template.hbs: -------------------------------------------------------------------------------- 1 |

Fastboot tests

2 | 3 |

4 | This page was rendered by fastboot, which made an AJAX 5 | request to get post 1. This request should be in the 6 | shoebox. 7 |

8 | 9 |
10 | {{model.title}} 11 |
12 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/index/template.hbs: -------------------------------------------------------------------------------- 1 | {{docs-hero}} 2 | 3 |
4 |
5 |
6 |
7 | 15 |
16 |
17 |
18 | 19 |
20 | {{index/x-intro}} 21 | 22 |
23 | {{#docs-link 'docs'}} 24 | {{#ui-button}} 25 | Get started → 26 | {{/ui-button}} 27 | {{/docs-link}} 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/index/x-intro/template.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Ember Data is powerful and flexible, but that flexibility doesn't always make it easy to do the right thing. As your app grows, it can get harder and harder to keep track of all your app's data. 4 | 5 | Here are some common problems that show up while working on apps that use Ember Data: 6 | 7 | - **Missing data.** It's not always clear whether all the data has been loaded for a given state of your application, especially as the number of app states compounds. Incomplete or missing data can cause problems when visiting routes, rendering components, or performing transforms on model relationships. 8 | 9 | - **Flashing templates.** Incomplete data that Ember Data attempts to lazily load can cause your templates to unexpectedly flash during a render, making your UI disorienting. 10 | 11 | - **Excessive network requests.** The n+1 templating bug can cause your app to make unnecessary AJAX requests and slow down your app. 12 | 13 | While you can solve each one of these issues on your own, Storefront's patterns help you avoid them in the first place. 14 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/playground/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { readOnly } from '@ember/object/computed'; 3 | import { computed } from '@ember/object'; 4 | 5 | export default Controller.extend({ 6 | post: readOnly('model'), 7 | 8 | comments: computed('post.comments', function() { 9 | return this.get('post').hasMany('comments').value(); 10 | }), 11 | 12 | actions: { 13 | createServerComment() { 14 | server.create('comment', { postId: this.get('post.id') }); 15 | }, 16 | 17 | async loadComments() { 18 | let returnValue = await this.get('post').load('comments'); 19 | if (!this.get('returnValue')) { 20 | this.set('returnValue', returnValue); 21 | } 22 | }, 23 | 24 | makeSiteSlow() { 25 | server.timing = 5000; 26 | } 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/playground/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | 5 | model() { 6 | return this.store.loadRecord('post', 1); 7 | } 8 | 9 | }); 10 | -------------------------------------------------------------------------------- /tests/dummy/app/pods/playground/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | The post has {{comments.length}} comments. 3 |
4 | 5 |
6 | The post has {{returnValue.length}} comments. 7 |
8 | 9 | 12 | 13 | | 14 | 15 | 18 | 19 | | 20 | 21 | 24 | -------------------------------------------------------------------------------- /tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from './config/environment'; 3 | import RouterScroll from 'ember-router-scroll'; 4 | 5 | const Router = EmberRouter.extend(RouterScroll, { 6 | location: config.locationType, 7 | rootURL: config.rootURL 8 | }); 9 | 10 | Router.map(function() { 11 | this.route('docs', function() { 12 | this.route('guides', function() { 13 | this.route('data-fetching'); 14 | this.route('working-with-relationships'); 15 | this.route('avoiding-errors'); 16 | this.route('fastboot'); 17 | this.route('common-data-issues'); 18 | }); 19 | 20 | this.route('api', function() { 21 | this.route('item', { path: '/*path' }); 22 | }); 23 | }); 24 | 25 | this.route('fastboot-tests', function() { 26 | this.route('load-all-posts'); 27 | this.route('load-record-post', { path: 'load-record-post/:post_id' }); 28 | this.route('find-all-posts'); 29 | }); 30 | 31 | this.route('playground'); 32 | }); 33 | 34 | export default Router; 35 | -------------------------------------------------------------------------------- /tests/dummy/app/serializers/application.js: -------------------------------------------------------------------------------- 1 | import JSONAPISerializer from '@ember-data/serializer/json-api'; 2 | 3 | export default JSONAPISerializer.extend(); 4 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import 'pod-styles'; 2 | 3 | $green: #7ED321; 4 | $green-dark: #417505; 5 | 6 | .bg-green { 7 | background-color: $green; 8 | } 9 | .bg-green-dark-1 { 10 | background-color: darken($green, 5%); 11 | } 12 | .hover-bg-green-dark-1 { 13 | &:hover { 14 | background-color: darken($green, 5%); 15 | } 16 | } 17 | 18 | .outline-none { 19 | outline: none; 20 | } 21 | .no-events { 22 | pointer-events: none; 23 | } 24 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{!-- The following component displays Ember's default welcome message. --}} 2 | 3 | {{!-- Feel free to remove this! --}} 4 | 5 | {{outlet}} -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(environment) { 4 | let ENV = { 5 | modulePrefix: 'dummy', 6 | podModulePrefix: 'dummy/pods', 7 | environment, 8 | rootURL: '/', 9 | locationType: 'router-scroll', 10 | historySupportMiddleware: true, 11 | 'ember-cli-mirage': { 12 | enabled: true 13 | }, 14 | EmberENV: { 15 | FEATURES: { 16 | // Here you can enable experimental features on an ember canary build 17 | // e.g. 'with-controller': true 18 | }, 19 | EXTEND_PROTOTYPES: { 20 | // Prevent Ember Data from overriding Date.parse. 21 | Date: false 22 | } 23 | }, 24 | 25 | APP: { 26 | // Here you can pass flags/options to your application instance 27 | // when it is created 28 | }, 29 | 30 | fastboot: { 31 | hostWhitelist: [/^localhost:\d+$/] 32 | } 33 | }; 34 | 35 | if (environment === 'development') { 36 | // ENV.APP.LOG_RESOLVER = true; 37 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 38 | // ENV.APP.LOG_TRANSITIONS = true; 39 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 40 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 41 | } 42 | 43 | if (environment === 'test') { 44 | // Testem prefers this... 45 | ENV.locationType = 'none'; 46 | 47 | // keep test console output quieter 48 | ENV.APP.LOG_ACTIVE_GENERATION = false; 49 | ENV.APP.LOG_VIEW_LOOKUPS = false; 50 | 51 | ENV.APP.rootElement = '#ember-testing'; 52 | ENV.APP.autoboot = false; 53 | 54 | ENV['ember-cli-mirage'] = { 55 | enabled: false 56 | }; 57 | } 58 | 59 | if (environment === 'production') { 60 | // Allow ember-cli-addon-docs to update the rootURL in compiled assets 61 | ENV.rootURL = 'ADDON_DOCS_ROOT_URL'; 62 | } 63 | 64 | return ENV; 65 | }; 66 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 2 Chrome versions', 5 | 'last 2 Firefox versions', 6 | 'last 2 Safari versions' 7 | ]; 8 | 9 | module.exports = { 10 | browsers 11 | }; 12 | -------------------------------------------------------------------------------- /tests/dummy/mirage/config.js: -------------------------------------------------------------------------------- 1 | let genericRelationshipRouteHandler = function(schema, request) { 2 | let collectionName = request.url.split('/').filter(part => part !== '')[0]; 3 | let relationship = request.params.relationship; 4 | let modelOrCollection = schema[collectionName].find(request.params.id)[relationship]; 5 | 6 | if (modelOrCollection) { 7 | return modelOrCollection; 8 | } else { 9 | return { data: null }; 10 | } 11 | }; 12 | 13 | export default function() { 14 | window.server = this; 15 | 16 | this.get('posts', { 17 | timing: 1000 18 | }); 19 | 20 | this.get('/posts/:id'); 21 | 22 | this.get('/posts/:id/relationships/:relationship', genericRelationshipRouteHandler); 23 | 24 | this.passthrough(); 25 | } 26 | -------------------------------------------------------------------------------- /tests/dummy/mirage/factories/comment.js: -------------------------------------------------------------------------------- 1 | import { Factory } from 'ember-cli-mirage'; 2 | 3 | export default Factory.extend({ 4 | }); 5 | -------------------------------------------------------------------------------- /tests/dummy/mirage/factories/post.js: -------------------------------------------------------------------------------- 1 | import { dasherize } from '@ember/string'; 2 | import { Factory, trait } from 'ember-cli-mirage'; 3 | 4 | export default Factory.extend({ 5 | title(i) { 6 | return `The title for post #${i + 1}`; 7 | }, 8 | 9 | text: "This is the text of the post.", 10 | 11 | afterCreate(post) { 12 | post.update({ slug: dasherize(post.title) }); 13 | }, 14 | 15 | withComments: trait({ 16 | afterCreate(post, server) { 17 | server.createList('comment', 3, { post }); 18 | } 19 | }) 20 | }); 21 | -------------------------------------------------------------------------------- /tests/dummy/mirage/scenarios/default.js: -------------------------------------------------------------------------------- 1 | /*eslint no-console: ["error", { allow: ["log"] }] */ 2 | 3 | export default function(server) { 4 | window.server = server; 5 | 6 | server.create('post', { 7 | id: 1, 8 | title: 'Lorem', 9 | comments: server.createList('comment', 3) 10 | }); 11 | 12 | server.create('post', { 13 | id: 2, 14 | title: 'Lorem', 15 | comments: server.createList('comment', 5) 16 | }); 17 | 18 | server.create('post', { title: 'Ipsum' }); 19 | server.create('post', { title: 'Dolor' }); 20 | // server.createList('post', 3); 21 | // 22 | // let interval = setInterval(() => { 23 | // console.log('[Mirage scenario] Creating a new post'); 24 | // 25 | // server.create('post'); 26 | // 27 | // if (server.db.posts.length >= 10) { 28 | // clearInterval(interval); 29 | // } 30 | // }, 1000); 31 | } 32 | -------------------------------------------------------------------------------- /tests/dummy/mirage/serializers/application.js: -------------------------------------------------------------------------------- 1 | import { JSONAPISerializer } from 'ember-cli-mirage'; 2 | 3 | export default JSONAPISerializer.extend({ 4 | 5 | alwaysIncludeLinkageData: false 6 | 7 | }); 8 | -------------------------------------------------------------------------------- /tests/dummy/mirage/serializers/post.js: -------------------------------------------------------------------------------- 1 | import ApplicationSerializer from './application'; 2 | 3 | export default ApplicationSerializer.extend({ 4 | 5 | links(model) { 6 | return { 7 | author: { 8 | related: `/posts/${model.id}/relationships/author` 9 | }, 10 | comments: { 11 | related: `/posts/${model.id}/relationships/comments` 12 | } 13 | }; 14 | } 15 | 16 | }); 17 | -------------------------------------------------------------------------------- /tests/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embermap/ember-data-storefront/414d632d9928ec033bda84a986f0a45a57cebd5a/tests/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/helpers/start-mirage.js: -------------------------------------------------------------------------------- 1 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 2 | import scenario from 'dummy/mirage/scenarios/default'; 3 | 4 | export default function(hooks) { 5 | hooks.beforeEach(function() { 6 | this.server = startMirage(); 7 | scenario(this.server); 8 | }) 9 | 10 | hooks.afterEach(function() { 11 | this.server.shutdown(); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/integration/-private/cache-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import Cache from 'ember-data-storefront/-private/cache'; 3 | import RecordQuery from 'ember-data-storefront/-private/record-query'; 4 | 5 | module('Integration | Cache test', function(hooks) { 6 | hooks.beforeEach(function() { 7 | this.mockStore = { 8 | peekRecord() {} 9 | }; 10 | }); 11 | 12 | hooks.afterEach(function() { 13 | delete this.mockStore; 14 | }); 15 | 16 | test('it can store a query with no params', function(assert) { 17 | let cache = new Cache(); 18 | let query = new RecordQuery(this.mockStore, 'post', 1); 19 | 20 | cache.put(query); 21 | 22 | assert.equal(cache.get('post', 1), query); 23 | }); 24 | 25 | test('it can store a query with simple params', function(assert) { 26 | let cache = new Cache(); 27 | let query = new RecordQuery(this.mockStore, 'post', 1, { 28 | testing: 123 29 | }); 30 | 31 | cache.put(query); 32 | 33 | assert.equal(cache.get('post', 1, { testing: 123 }), query); 34 | }); 35 | 36 | test("the order of the params doesn't matter", function(assert) { 37 | let cache = new Cache(); 38 | let query = new RecordQuery(this.mockStore, 'post', 1, { 39 | key1: 'A', 40 | key2: 'B' 41 | }); 42 | 43 | cache.put(query); 44 | 45 | let cachedQuery = cache.get('post', 1, { 46 | key2: 'B', 47 | key1: 'A' 48 | }); 49 | assert.equal(cachedQuery, query); 50 | }); 51 | 52 | test('it can store a query with nested params', function(assert) { 53 | let cache = new Cache(); 54 | let query = new RecordQuery(this.mockStore, 'post', 1, { 55 | filter: { 56 | testing: 123 57 | } 58 | }); 59 | 60 | cache.put(query); 61 | 62 | assert.equal(cache.get('post', 1, { filter: { testing: 123 } }), query); 63 | }); 64 | 65 | test('it can store a query with boolean params', function(assert) { 66 | let cache = new Cache(); 67 | let query = new RecordQuery(this.mockStore, 'post', 1, { 68 | foo: true, 69 | bar: false 70 | }); 71 | 72 | cache.put(query); 73 | 74 | assert.equal(cache.get('post', 1, { foo: true, bar: false }), query); 75 | }); 76 | 77 | test('it should be able to get all queries out of the cache', function(assert) { 78 | let cache = new Cache(); 79 | let query1 = new RecordQuery(this.mockStore, 'post', 1); 80 | let query2 = new RecordQuery(this.mockStore, 'post', 2); 81 | 82 | cache.put(query1); 83 | cache.put(query2); 84 | 85 | assert.deepEqual(cache.all(), [query1, query2]); 86 | }); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /tests/integration/changing-data-render-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render, settled } from '@ember/test-helpers'; 4 | import hbs from 'htmlbars-inline-precompile'; 5 | import MirageServer from 'dummy/tests/integration/helpers/mirage-server'; 6 | import { Model } from 'ember-cli-mirage'; 7 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 8 | 9 | module('Integration | Changing data render test', function(hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | hooks.beforeEach(function() { 13 | this.server = new MirageServer({ 14 | models: { 15 | post: Model.extend() 16 | }, 17 | baseConfig() { 18 | this.resource('posts'); 19 | } 20 | }); 21 | this.store = this.owner.lookup('service:store') 22 | this.store.reopen(LoadableStore); 23 | this.store.resetCache(); 24 | }); 25 | 26 | hooks.afterEach(function() { 27 | this.server.shutdown(); 28 | }); 29 | 30 | test('record queries trigger template rerenders', async function(assert) { 31 | let serverPost = this.server.create('post', { title: 'Lorem' }); 32 | let postId = serverPost.id; 33 | 34 | await this.store.loadRecord('post', postId).then(post => { 35 | this.set('model', post); 36 | }); 37 | 38 | await render(hbs` 39 |
40 | {{model.title}} 41 |
42 | `); 43 | 44 | assert.dom('[data-test-title]').hasText('Lorem'); 45 | 46 | this.server.schema.posts.find(serverPost.id).update('title', 'ipsum'); 47 | 48 | await this.store.loadRecord('post', postId, { reload: true }); 49 | await settled(); 50 | 51 | assert.dom('[data-test-title]').hasText('ipsum'); 52 | }); 53 | 54 | test('record array queries trigger template rerenders', async function(assert) { 55 | this.server.createList('post', 2); 56 | 57 | await this.store.loadRecords('post').then(posts => { 58 | this.set('model', posts); 59 | }); 60 | 61 | await render(hbs` 62 |
    63 | {{#each model as |post|}} 64 |
  • {{post.id}}
  • 65 | {{/each}} 66 |
67 | `); 68 | 69 | assert.dom('li').exists({ count: 2 }); 70 | 71 | this.server.create('post'); 72 | await this.get('model').update(); 73 | await settled(); 74 | 75 | assert.dom('li').exists({ count: 3 }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/integration/components/assert-must-preload-test.js: -------------------------------------------------------------------------------- 1 | import Model from '@ember-data/model'; 2 | import { module, test } from 'qunit'; 3 | import { setupRenderingTest } from 'ember-qunit'; 4 | import { render } from '@ember/test-helpers'; 5 | import { run } from '@ember/runloop'; 6 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 7 | import hbs from 'htmlbars-inline-precompile'; 8 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 9 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 10 | import Ember from 'ember'; 11 | 12 | module('Integration | Component | assert must preload', function(hooks) { 13 | setupRenderingTest(hooks); 14 | 15 | // keep an eye on this issue: 16 | // https://github.com/emberjs/ember-test-helpers/issues/310 17 | let onerror; 18 | let adapterException; 19 | let loggerError; 20 | 21 | hooks.beforeEach(function() { 22 | Model.reopen(LoadableModel); 23 | this.store = this.owner.lookup('service:store') 24 | this.store.reopen(LoadableStore); 25 | this.store.resetCache(); 26 | this.server = startMirage(); 27 | onerror = Ember.onerror; 28 | adapterException = Ember.Test.adapter.exception 29 | loggerError = Ember.Logger.error; 30 | 31 | // the next line doesn't work in 2.x due to an eslint rule 32 | // eslint-disable-next-line 33 | [ this.major, this.minor ] = Ember.VERSION.split("."); 34 | 35 | // setup a bunch of data that our tests will load 36 | let author = this.server.create('author'); 37 | let post = this.server.create('post', { 38 | id: 1, 39 | title: 'Post title', 40 | author 41 | }); 42 | let comments = this.server.createList('comment', 3, { post }); 43 | 44 | comments.forEach(comment => { 45 | server.create('author', { comments: [comment] }); 46 | }); 47 | }); 48 | 49 | hooks.afterEach(function() { 50 | Ember.onerror = onerror; 51 | Ember.Test.adapter.exception = adapterException; 52 | Ember.Logger.error = loggerError; 53 | this.server.shutdown(); 54 | }); 55 | 56 | test('it errors if the relationship has not yet be loaded', async function(assert) { 57 | this.post = await run(() => { 58 | return this.store.loadRecord('post', 1); 59 | }); 60 | 61 | let assertError = function(e) { 62 | let regexp = /You tried to render a .+ that accesses relationships off of a post, but that model didn't have all of its required relationships preloaded ('comments')*/; 63 | assert.ok(e.message.match(regexp)); 64 | }; 65 | 66 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 67 | Ember.Logger.error = function() {}; 68 | Ember.Test.adapter.exception = assertError; 69 | } else { 70 | Ember.onerror = assertError; 71 | } 72 | 73 | await render(hbs` 74 | {{assert-must-preload post "comments"}} 75 | `); 76 | }); 77 | 78 | test('it errors if one of the relationships has not yet be loaded', async function(assert) { 79 | this.post = await run(() => { 80 | return this.store.loadRecord('post', 1, { include: 'author' }); 81 | }); 82 | 83 | let assertError = function(e) { 84 | let regexp = /You tried to render a .+ that accesses relationships off of a post, but that model didn't have all of its required relationships preloaded ('comments')*/; 85 | assert.ok(e.message.match(regexp)); 86 | }; 87 | 88 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 89 | Ember.Logger.error = function() {}; 90 | Ember.Test.adapter.exception = assertError; 91 | } else { 92 | Ember.onerror = assertError; 93 | } 94 | 95 | await render(hbs` 96 | {{assert-must-preload post "author,comments"}} 97 | `); 98 | }); 99 | 100 | test('it errors if a nested relationship has not yet be loaded', async function(assert) { 101 | this.post = await run(() => { 102 | return this.store.loadRecord('post', 1, { include: 'comments' }); 103 | }); 104 | 105 | let assertError = function(e) { 106 | let regexp = /You tried to render a .+ that accesses relationships off of a post, but that model didn't have all of its required relationships preloaded ('comments.author')*/; 107 | assert.ok(e.message.match(regexp)); 108 | }; 109 | 110 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 111 | Ember.Logger.error = function() {}; 112 | Ember.Test.adapter.exception = assertError; 113 | } else { 114 | Ember.onerror = assertError; 115 | } 116 | 117 | await render(hbs` 118 | {{assert-must-preload post "comments.author"}} 119 | `); 120 | }); 121 | 122 | test('it does not error if the relationship was loaded', async function(assert) { 123 | this.post = await run(() => { 124 | return this.store.loadRecord('post', 1, { include: 'comments' }); 125 | }); 126 | 127 | await render(hbs` 128 | {{assert-must-preload post "comments"}} 129 | `); 130 | 131 | // if anything renders, we're ok 132 | assert.dom('*').hasText(''); 133 | }); 134 | 135 | module('Data loaded with loadRecords', function() { 136 | test('it should not error when all data is loaded', async function(assert) { 137 | let posts = await run(() => { 138 | return this.store.loadRecords('post', { include: 'comments' }); 139 | }); 140 | 141 | this.post = posts.get('firstObject'); 142 | 143 | await render(hbs` 144 | {{assert-must-preload post "comments"}} 145 | 146 |
147 | {{post.title}} 148 |
149 | `); 150 | 151 | assert.dom('[data-test-id="title"]').hasText("Post title"); 152 | }); 153 | 154 | test('it should error is not all data is loaded', async function(assert) { 155 | let posts = await run(() => { 156 | return this.store.loadRecords('post', { include: 'comments,author' }); 157 | }); 158 | 159 | this.post = posts.get('firstObject'); 160 | 161 | let assertError = function(e) { 162 | let regexp = /You tried to render a .+ that accesses relationships off of a post, but that model didn't have all of its required relationships preloaded ('comments.author')*/; 163 | assert.ok(e.message.match(regexp)); 164 | }; 165 | 166 | if (this.major === "2" && (this.minor === "12" || this.minor === "16")) { 167 | Ember.Logger.error = function() {}; 168 | Ember.Test.adapter.exception = assertError; 169 | } else { 170 | Ember.onerror = assertError; 171 | } 172 | 173 | await render(hbs` 174 | {{assert-must-preload post "author,comments.author"}} 175 | `); 176 | }); 177 | }); 178 | 179 | }); 180 | -------------------------------------------------------------------------------- /tests/integration/components/load-records-example-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import Component from '@ember/component'; 4 | import hbs from 'htmlbars-inline-precompile'; 5 | import { inject as service } from '@ember/service'; 6 | import { render } from '@ember/test-helpers'; 7 | import Model from '@ember-data/model'; 8 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 9 | 10 | module('Integration | Component | Load records example', function(hooks) { 11 | setupRenderingTest(hooks); 12 | 13 | hooks.beforeEach(function() { 14 | this.owner.register('model:user', Model.extend()); 15 | this.owner.register('component:load-records', Component.extend({ 16 | store: service(), 17 | didInsertElement() { 18 | this._super(...arguments); 19 | 20 | this.get('store').loadRecords(this.modelName, { ...this.params }); 21 | } 22 | })); 23 | this.server = startMirage(); 24 | }); 25 | 26 | hooks.afterEach(function() { 27 | this.server.shutdown(); 28 | }); 29 | 30 | // This ensures users can write a component. See https://github.com/embermap/ember-data-storefront/issues/79. 31 | test('users should be able to invoke #loadRecords using a hash from a template', async function(assert) { 32 | this.server.get('/users', () => ({ data: []})); 33 | 34 | await render(hbs` 35 | {{load-records 36 | modelName='user' 37 | params=(hash 38 | sort='-position' 39 | page=(hash limit=4) 40 | ) 41 | }} 42 | `); 43 | 44 | assert.ok(true); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/integration/helpers/mirage-server.js: -------------------------------------------------------------------------------- 1 | import Server from 'ember-cli-mirage/server'; 2 | import { JSONAPISerializer } from 'ember-cli-mirage'; 3 | import { assign } from '@ember/polyfills'; 4 | 5 | export default function(overrides) { 6 | let defaults = { 7 | environment: 'test', 8 | serializers: { 9 | application: JSONAPISerializer 10 | } 11 | }; 12 | let config = assign({}, defaults, overrides); 13 | 14 | let server = new Server(config); 15 | 16 | // Have to override after instantiation, because `test` env sets to 0 17 | server.timing = 5; 18 | 19 | return server; 20 | } 21 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-model/has-loaded-test.js: -------------------------------------------------------------------------------- 1 | import Model from '@ember-data/model'; 2 | import { module, test } from 'qunit'; 3 | import { setupTest } from 'ember-qunit'; 4 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 5 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 6 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 7 | 8 | module('Integration | Mixins | LoadableModel | hasLoaded', function(hooks) { 9 | setupTest(hooks); 10 | 11 | hooks.beforeEach(function() { 12 | Model.reopen(LoadableModel); 13 | this.server = startMirage(); 14 | 15 | this.store = this.owner.lookup('service:store'); 16 | this.store.reopen(LoadableStore); 17 | this.store.resetCache(); 18 | 19 | let author = server.create('author', { id: 1 }); 20 | let post = server.create('post', { id: 1, author }); 21 | server.createList('comment', 2, { post, author }); 22 | }), 23 | 24 | hooks.afterEach(function() { 25 | this.server.shutdown(); 26 | this.store = null; 27 | }); 28 | 29 | test('#hasLoaded returns true if a relationship has been loaded', async function(assert) { 30 | let post = await this.store.findRecord('post', 1); 31 | 32 | await post.load('comments'); 33 | 34 | assert.ok(post.hasLoaded('comments')); 35 | }); 36 | 37 | test('#hasLoaded returns true if a relationship has been sideloaded', async function(assert) { 38 | let post = await this.store.findRecord('post', 1); 39 | 40 | await post.sideload('comments'); 41 | 42 | assert.ok(post.hasLoaded('comments')); 43 | }); 44 | 45 | test('#hasLoaded returns true if the relationship chain has been sideloaded', async function(assert) { 46 | let post = await this.store.findRecord('post', 1); 47 | 48 | await post.sideload('comments.author'); 49 | 50 | assert.ok(post.hasLoaded('comments.author')); 51 | }); 52 | 53 | test('#hasLoaded returns false if the relationship has not been sideloaded', async function(assert) { 54 | let post = await this.store.findRecord('post', 1); 55 | 56 | assert.notOk(post.hasLoaded('comments')); 57 | }); 58 | 59 | test('#hasLoaded returns false if another relationship has not been sideloaded', async function(assert) { 60 | let post = await this.store.findRecord('post', 1); 61 | 62 | await post.sideload('comments'); 63 | 64 | assert.notOk(post.hasLoaded('tags')); 65 | }); 66 | 67 | test('#hasLoaded returns false if a relationship chain has not been fully sideloaded', async function(assert) { 68 | let post = await this.store.findRecord('post', 1); 69 | 70 | await post.sideload('comments'); 71 | 72 | assert.notOk(post.hasLoaded('comments.author')); 73 | }); 74 | 75 | test('#hasLoaded returns false for similarly named relationships', async function(assert) { 76 | let post = await this.store.findRecord('post', 1); 77 | 78 | await post.sideload('comments.author'); 79 | 80 | assert.notOk(post.hasLoaded('author')); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-model/load-test.js: -------------------------------------------------------------------------------- 1 | import Model from '@ember-data/model'; 2 | import { module, test } from 'qunit'; 3 | import { setupTest } from 'ember-qunit'; 4 | import { waitUntil } from '@ember/test-helpers'; 5 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 6 | import { run } from '@ember/runloop'; 7 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 8 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 9 | 10 | module('Integration | Mixins | LoadableModel | load', function(hooks) { 11 | setupTest(hooks); 12 | 13 | hooks.beforeEach(function() { 14 | Model.reopen(LoadableModel); 15 | this.server = startMirage(); 16 | 17 | this.store = this.owner.lookup('service:store'); 18 | this.store.reopen(LoadableStore); 19 | this.store.resetCache(); 20 | 21 | let author = server.create('author', { id: 1 }); 22 | let post = server.create('post', { id: 1, author }); 23 | server.createList('comment', 2, { post, author }); 24 | }), 25 | 26 | hooks.afterEach(function() { 27 | this.server.shutdown(); 28 | this.store = null; 29 | }); 30 | 31 | test('#load errors when attempting to load multiple relationships', async function(assert) { 32 | let post = await run(() => { 33 | return this.store.findRecord('post', 1) 34 | }); 35 | 36 | assert.throws( 37 | () => { post.load('comments.author'); }, 38 | /The #load method only works with a single relationship/ 39 | ); 40 | }); 41 | 42 | test('#load errors when given a relationship name that does not exist', async function(assert) { 43 | let post = await run(() => { 44 | return this.store.findRecord('post', 1) 45 | }); 46 | 47 | assert.throws( 48 | () => { post.load('citations'); }, 49 | /You tried to load the relationship citations for a post, but that relationship does not exist/ 50 | ); 51 | }); 52 | 53 | test('#load can load a belongsTo relationship', async function(assert) { 54 | let requests = []; 55 | server.pretender.handledRequest = function(...args) { 56 | requests.push(args[2]); 57 | }; 58 | 59 | let post = await run(() => this.store.findRecord('post', 1)); 60 | assert.equal(post.belongsTo('author').value(), null); 61 | 62 | let author = await run(() => post.load('author')); 63 | 64 | assert.equal(post.belongsTo('author').value(), author); 65 | assert.equal(requests.length, 2); 66 | assert.equal(requests[1].url, '/posts/1/relationships/author'); 67 | }); 68 | 69 | test('#load can load a hasMany relationship', async function(assert) { 70 | let requests = []; 71 | server.pretender.handledRequest = function(...args) { 72 | requests.push(args[2]); 73 | }; 74 | 75 | let post = await run(() => this.store.findRecord('post', 1)); 76 | assert.equal(post.hasMany('comments').value(), null); 77 | 78 | let comments = await run(() => post.load('comments')); 79 | 80 | assert.equal(post.hasMany('comments').value(), comments); 81 | assert.equal(requests.length, 2); 82 | assert.equal(requests[1].url, '/posts/1/relationships/comments'); 83 | }); 84 | 85 | test('#load should not use a blocking fetch if the relationship has already been loaded', async function(assert) { 86 | let requests = []; 87 | 88 | server.pretender.handledRequest = function(...args) { 89 | requests.push(args[2]); 90 | }; 91 | 92 | // first load waits and blocks 93 | let post = await run(() => { 94 | return this.store.findRecord('post', 1, { include: 'comments' }); 95 | }); 96 | assert.equal(post.hasMany('comments').value().length, 2); 97 | 98 | // kind of britle, but we want to slow the server down a little 99 | // so we can be sure our test is blocked by the next call to load. 100 | server.timing = 500; 101 | 102 | // second load doesnt block, instantly returns 103 | await run(() => { 104 | return post.load('comments'); 105 | }); 106 | assert.equal(requests.length, 1); 107 | 108 | // dont let test finish until second test does a background reload 109 | await waitUntil(() => requests.length === 2, { timeout: 5000 }); 110 | }); 111 | 112 | test('#load should use a blocking fetch if the relationship has already been loaded, but the reload option is true', async function(assert) { 113 | let requests = []; 114 | server.pretender.handledRequest = function(...args) { 115 | requests.push(args[2]); 116 | }; 117 | 118 | let post = await run(() => this.store.findRecord('post', 1, { include: 'comments' })); 119 | assert.equal(post.hasMany('comments').value().length, 2); 120 | 121 | let comments = await run(() => { 122 | return post.load('comments', { reload: true }); 123 | }); 124 | 125 | assert.equal(post.hasMany('comments').value(), comments); 126 | assert.equal(requests.length, 2); 127 | assert.equal(requests[1].url, '/posts/1/relationships/comments'); 128 | }); 129 | 130 | test('#load should not make a network request if the relationship is loaded, but backgroundReload is false', async function(assert) { 131 | let requests = []; 132 | 133 | server.pretender.handledRequest = function(...args) { 134 | requests.push(args[2]); 135 | }; 136 | 137 | // first load waits and blocks 138 | let post = await run(() => { 139 | return this.store.findRecord('post', 1, { include: 'comments' }); 140 | }); 141 | assert.equal(post.hasMany('comments').value().length, 2); 142 | 143 | // second load doesnt block, instantly returns 144 | await run(() => { 145 | return post.load('comments', { backgroundReload: false }); 146 | }); 147 | assert.equal(requests.length, 1); 148 | 149 | // wait 500ms and make sure there's no network request 150 | await new Promise(resolve => setTimeout(resolve, 500)); 151 | 152 | assert.equal(requests.length, 1); 153 | }); 154 | 155 | test('#load should make a network request if the relationship has not been loaded, but the backgroundReload option is false', async function(assert) { 156 | let requests = []; 157 | server.pretender.handledRequest = function(...args) { 158 | requests.push(args[2]); 159 | }; 160 | 161 | let post = await run(() => this.store.findRecord('post', 1)); 162 | assert.equal(post.hasMany('comments').value(), null); 163 | 164 | let comments = await run(() => { 165 | return post.load('comments', { backgroundReload: false }); 166 | }); 167 | 168 | assert.equal(post.hasMany('comments').value(), comments); 169 | assert.equal(requests.length, 2); 170 | assert.equal(requests[1].url, '/posts/1/relationships/comments'); 171 | }); 172 | 173 | test('#load should update the reference from an earlier load call', async function(assert) { 174 | let post = await run(() => this.store.findRecord('post', 1)); 175 | 176 | let comments = await run(() => post.load('comments')); 177 | assert.equal(comments.length, 2); 178 | 179 | server.create('comment', { postId: post.id }); 180 | 181 | run(() => post.load('comments')); 182 | 183 | assert.equal(comments.length, 2); 184 | await waitUntil(() => comments.length === 3); 185 | }); 186 | 187 | }); 188 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-model/sideload-test.js: -------------------------------------------------------------------------------- 1 | import Model from '@ember-data/model'; 2 | import { module, test } from 'qunit'; 3 | import { setupTest } from 'ember-qunit'; 4 | import { settled, waitUntil } from '@ember/test-helpers'; 5 | import { startMirage } from 'dummy/initializers/ember-cli-mirage'; 6 | import LoadableModel from 'ember-data-storefront/mixins/loadable-model'; 7 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 8 | 9 | module('Integration | Mixins | LoadableModel | sideload', function(hooks) { 10 | setupTest(hooks); 11 | 12 | hooks.beforeEach(function() { 13 | Model.reopen(LoadableModel); 14 | this.server = startMirage(); 15 | 16 | this.store = this.owner.lookup('service:store'); 17 | this.store.reopen(LoadableStore); 18 | this.store.resetCache(); 19 | 20 | let author = server.create('author', { id: 1 }); 21 | let post = server.create('post', { id: 1, author }); 22 | server.createList('comment', 2, { post, author }); 23 | }), 24 | 25 | hooks.afterEach(function() { 26 | this.server.shutdown(); 27 | this.store = null; 28 | }); 29 | 30 | test('#sideload can load includes', async function(assert) { 31 | let requests = []; 32 | server.pretender.handledRequest = function(...args) { 33 | requests.push(args[2]); 34 | }; 35 | 36 | let post = await this.store.findRecord('post', 1) 37 | 38 | assert.equal(post.hasMany('comments').value(), null); 39 | 40 | post = await post.sideload('comments'); 41 | 42 | assert.equal(post.hasMany('comments').value().get('length'), 2); 43 | assert.equal(requests.length, 2); 44 | assert.equal(requests[1].url, '/posts/1?include=comments'); 45 | }); 46 | 47 | test('#sideload returns a resolved promise if its already loaded includes, and reloads in the background', async function(assert) { 48 | let serverCalls = 0; 49 | server.pretender.handledRequest = function() { serverCalls++ }; 50 | 51 | let post = await this.store.findRecord('post', 1) 52 | 53 | assert.equal(serverCalls, 1); 54 | 55 | // kind of britle, but we want to slow the server down a little 56 | // so we can be sure our test is blocked by the next call to load. 57 | server.timing = 500; 58 | 59 | await post.sideload('comments'); 60 | 61 | assert.equal(serverCalls, 2); 62 | assert.equal(post.hasMany('comments').value().get('length'), 2); 63 | server.create('comment', { postId: 1 }); 64 | 65 | await post.sideload('comments'); 66 | 67 | assert.equal(serverCalls, 2); 68 | await waitUntil(() => serverCalls === 3); 69 | await settled(); 70 | 71 | assert.equal(post.hasMany('comments').value().get('length'), 3); 72 | }); 73 | 74 | test('#sideload can take multiple arguments', async function(assert) { 75 | let tag = server.create('tag'); 76 | let miragePost = this.server.schema.posts.find(1); 77 | miragePost.update({ tags: [ tag ]}); 78 | 79 | let post = await this.store.findRecord('post', 1) 80 | 81 | await post.sideload('comments', 'tags'); 82 | 83 | assert.equal(post.hasMany('comments').value().get('length'), 2); 84 | assert.equal(post.hasMany('tags').value().get('length'), 1); 85 | }); 86 | 87 | test('#sideload can take options, like reload: true', async function(assert) { 88 | let serverCalls = 0; 89 | server.pretender.handledRequest = function() { serverCalls++ }; 90 | 91 | let post = await this.store.findRecord('post', 1, { include: 'comments' }); 92 | 93 | assert.equal(serverCalls, 1); 94 | 95 | await post.sideload('comments', { reload: true }); 96 | 97 | assert.equal(serverCalls, 2); 98 | }); 99 | 100 | test('#sideload should not make a network request if the relationship is loaded, but backgroundReload is false', async function(assert) { 101 | let requests = []; 102 | 103 | server.pretender.handledRequest = function(...args) { 104 | requests.push(args[2]); 105 | }; 106 | 107 | // first load waits and blocks 108 | let post = await this.store.loadRecord('post', 1, { include: 'comments' }); 109 | assert.equal(post.hasMany('comments').value().length, 2); 110 | 111 | // second load doesnt block, instantly returns 112 | await post.sideload('comments', { backgroundReload: false }); 113 | assert.equal(requests.length, 1); 114 | 115 | // wait 500ms and make sure there's no network request 116 | await new Promise(resolve => setTimeout(resolve, 500)); 117 | 118 | assert.equal(requests.length, 1); 119 | }); 120 | 121 | }); 122 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-store/has-loaded-includes-for-record-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { Model, hasMany, belongsTo } from 'ember-cli-mirage'; 4 | import MirageServer from 'dummy/tests/integration/helpers/mirage-server'; 5 | import { run } from '@ember/runloop'; 6 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 7 | 8 | module('Integration | Mixins | LoadableStore | hasLoadedIncludesForRecord', function(hooks) { 9 | setupTest(hooks); 10 | 11 | hooks.beforeEach(function() { 12 | this.server = new MirageServer({ 13 | models: { 14 | // eslint-disable-next-line ember/no-new-mixins 15 | post: Model.extend({ 16 | comments: hasMany(), 17 | tags: hasMany() 18 | }), 19 | // eslint-disable-next-line ember/no-new-mixins 20 | comment: Model.extend({ 21 | post: belongsTo() 22 | }), 23 | // eslint-disable-next-line ember/no-new-mixins 24 | tag: Model.extend({ 25 | posts: hasMany() 26 | }) 27 | }, 28 | baseConfig() { 29 | this.resource('posts'); 30 | } 31 | }); 32 | 33 | this.store = this.owner.lookup('service:store') 34 | this.store.reopen(LoadableStore); 35 | this.store.resetCache(); 36 | }); 37 | 38 | hooks.afterEach(function() { 39 | this.server.shutdown(); 40 | }); 41 | 42 | test('it returns true if the relationship has been loaded', async function(assert) { 43 | let serverPost = this.server.create('post'); 44 | this.server.createList('comment', 3, { post: serverPost }); 45 | 46 | await run(() => { 47 | return this.store.loadRecord('post', serverPost.id, { 48 | include: 'comments' 49 | }); 50 | }); 51 | 52 | assert.ok(this.store.hasLoadedIncludesForRecord('post', serverPost.id, 'comments')); 53 | }); 54 | 55 | test('it returns false if the relationship has not been loaded', async function(assert) { 56 | let serverPost = this.server.create('post'); 57 | this.server.createList('comment', 3, { post: serverPost }); 58 | 59 | await run(() => { 60 | return this.store.loadRecord('post', serverPost.id); 61 | }); 62 | 63 | assert.notOk(this.store.hasLoadedIncludesForRecord('post', serverPost.id, 'comments')); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-store/load-record-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { waitUntil } from '@ember/test-helpers'; 4 | import MirageServer from 'dummy/tests/integration/helpers/mirage-server'; 5 | import { Model, hasMany, belongsTo } from 'ember-cli-mirage'; 6 | import { run } from '@ember/runloop'; 7 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 8 | 9 | module('Integration | Mixins | LoadableStore | loadRecord', function(hooks) { 10 | setupTest(hooks); 11 | 12 | hooks.beforeEach(function() { 13 | this.server = new MirageServer({ 14 | models: { 15 | // eslint-disable-next-line ember/no-new-mixins 16 | post: Model.extend({ 17 | comments: hasMany(), 18 | author: belongsTo(), 19 | tags: hasMany() 20 | }), 21 | // eslint-disable-next-line ember/no-new-mixins 22 | comment: Model.extend({ 23 | post: belongsTo(), 24 | author: belongsTo() 25 | }), 26 | // eslint-disable-next-line ember/no-new-mixins 27 | tag: Model.extend({ 28 | posts: hasMany() 29 | }), 30 | // eslint-disable-next-line ember/no-new-mixins 31 | author: Model.extend({ 32 | comments: hasMany(), 33 | posts: hasMany() 34 | }) 35 | }, 36 | baseConfig() { 37 | this.resource('posts'); 38 | } 39 | }); 40 | 41 | this.store = this.owner.lookup('service:store'); 42 | this.store.reopen(LoadableStore); 43 | this.store.resetCache(); 44 | }); 45 | 46 | hooks.afterEach(function() { 47 | this.server.shutdown(); 48 | }); 49 | 50 | test('it can load a record', async function(assert) { 51 | let serverPost = this.server.create('post'); 52 | 53 | let post = await run(() => { 54 | return this.store.loadRecord('post', serverPost.id); 55 | }); 56 | 57 | assert.equal(post.get('id'), serverPost.id); 58 | }); 59 | 60 | test('it resolves immediately with an already-loaded record, then reloads it in the background', async function(assert) { 61 | let serverPost = this.server.create('post', { title: 'My post' }); 62 | let serverCalls = 0; 63 | this.server.pretender.handledRequest = () => serverCalls++; 64 | 65 | await run(() => { 66 | return this.store.loadRecord('post', serverPost.id); 67 | }); 68 | 69 | let post = await run(() => { 70 | return this.store.loadRecord('post', serverPost.id); 71 | }); 72 | 73 | assert.equal(serverCalls, 1); 74 | assert.equal(post.get('title'), 'My post'); 75 | 76 | await waitUntil(() => serverCalls === 2); 77 | }); 78 | 79 | test('it forces already-loaded records to fetch with the reload option', async function(assert) { 80 | let serverPost = this.server.create('post'); 81 | let serverCalls = 0; 82 | this.server.pretender.handledRequest = function(method, url, request) { 83 | serverCalls++; 84 | 85 | // the reload qp should not be sent 86 | assert.ok(!request.queryParams.reload); 87 | }; 88 | 89 | await run(() => { 90 | return this.store.loadRecord('post', serverPost.id, { reload: true }); 91 | }); 92 | 93 | await run(() => { 94 | return this.store.loadRecord('post', serverPost.id, { reload: true }); 95 | }); 96 | 97 | assert.equal(serverCalls, 2); 98 | }); 99 | 100 | test('it can load a record with includes', async function(assert) { 101 | let serverPost = this.server.create('post'); 102 | this.server.createList('comment', 3, { post: serverPost }); 103 | 104 | let post = await run(() => { 105 | return this.store.loadRecord('post', serverPost.id, { 106 | include: 'comments' 107 | }); 108 | }); 109 | 110 | assert.equal(post.get('id'), serverPost.id); 111 | assert.equal(post.get('comments.length'), 3); 112 | }); 113 | 114 | test(`it resolves immediately with an already-loaded includes query, then reloads it in the background`, async function(assert) { 115 | let serverPost = this.server.create('post', { title: 'My post' }); 116 | this.server.createList('comment', 3, { post: serverPost }); 117 | let serverCalls = []; 118 | this.server.pretender.handledRequest = function(verb, url) { 119 | serverCalls.push(url); 120 | }; 121 | 122 | await run(() => { 123 | return this.store.loadRecord('post', serverPost.id, { 124 | include: 'comments' 125 | }); 126 | }); 127 | 128 | assert.equal(serverCalls.length, 1); 129 | 130 | let post = await run(() => { 131 | return this.store.loadRecord('post', serverPost.id, { 132 | include: 'comments' 133 | }); 134 | }); 135 | 136 | assert.equal(serverCalls.length, 1); 137 | assert.equal(serverCalls[0], '/posts/1?include=comments'); 138 | assert.equal(post.get('comments.length'), 3); 139 | 140 | await waitUntil(() => serverCalls.length === 2); 141 | 142 | assert.equal(serverCalls[1], '/posts/1?include=comments'); 143 | }); 144 | 145 | test('it blocks when including an association for the first time', async function(assert) { 146 | let serverPost = this.server.create('post'); 147 | this.server.createList('comment', 3, { post: serverPost }); 148 | let serverCalls = 0; 149 | this.server.pretender.handledRequest = () => serverCalls++; 150 | 151 | let post = await run(() => { 152 | return this.store.loadRecord('post', serverPost.id); 153 | }); 154 | 155 | assert.equal(post.get('id'), serverPost.id); 156 | assert.equal(post.hasMany('comments').value(), null); 157 | assert.equal(serverCalls, 1); 158 | 159 | post = await run(() => { 160 | return this.store.loadRecord('post', serverPost.id, { 161 | include: 'comments' 162 | }); 163 | }); 164 | 165 | assert.equal(serverCalls, 2); 166 | assert.equal(post.hasMany('comments').value().get('length'), 3); 167 | }); 168 | 169 | test('it resolves immediately with an includes-only query whose relationships have already been loaded', async function(assert) { 170 | let serverPost = this.server.create('post'); 171 | this.server.createList('comment', 3, { post: serverPost }); 172 | this.server.createList('tag', 2, { posts: [ serverPost ] }); 173 | let serverCalls = 0; 174 | this.server.pretender.handledRequest = () => serverCalls++; 175 | 176 | let post = await run(() => { 177 | return this.store.loadRecord('post', serverPost.id, { 178 | include: 'comments' 179 | }); 180 | }); 181 | 182 | assert.equal(post.hasMany('comments').value().get('length'), 3); 183 | assert.equal(serverCalls, 1); 184 | 185 | post = await run(() => { 186 | return this.store.loadRecord('post', serverPost.id, { 187 | include: 'tags' 188 | }); 189 | }); 190 | 191 | assert.equal(post.hasMany('tags').value().get('length'), 2); 192 | 193 | post = await run(() => { 194 | return this.store.loadRecord('post', serverPost.id, { 195 | include: 'tags,comments' 196 | }); 197 | }); 198 | 199 | assert.equal(serverCalls, 2); 200 | }); 201 | 202 | test('loadRecord resolves immediately if its called with no options and the record is already in the store from loadRecords, then reloads it in the background', async function(assert) { 203 | let serverPost = this.server.create('post', { title: 'My post' }); 204 | let serverCalls = 0; 205 | this.server.pretender.handledRequest = function() { 206 | serverCalls++; 207 | }; 208 | 209 | await run(() => { 210 | return this.store.loadRecords('post'); 211 | }); 212 | 213 | let post = await run(() => { 214 | return this.store.loadRecord('post', serverPost.id); 215 | }); 216 | 217 | assert.equal(serverCalls, 1); 218 | assert.equal(post.get('title'), 'My post'); 219 | 220 | await waitUntil(() => serverCalls === 2); 221 | }); 222 | 223 | test('loadRecord blocks if its called with an includes, even if the record has already been loaded from loadRecords', async function(assert) { 224 | let serverPost = this.server.create('post', { title: 'My post' }); 225 | let serverCalls = 0; 226 | this.server.pretender.handledRequest = function() { 227 | serverCalls++; 228 | }; 229 | 230 | await run(() => { 231 | return this.store.loadRecords('post'); 232 | }); 233 | 234 | let post = await run(() => { 235 | return this.store.loadRecord('post', serverPost.id, { 236 | include: 'comments' 237 | }); 238 | }); 239 | 240 | assert.equal(serverCalls, 2); 241 | assert.equal(post.get('title'), 'My post'); 242 | }); 243 | 244 | test('loadRecord should not refresh the model in the background if background reload is false', async function(assert) { 245 | let serverPost = this.server.create('post', { title: 'My post' }); 246 | let serverCalls = 0; 247 | 248 | this.server.pretender.handledRequest = function() { 249 | serverCalls++; 250 | }; 251 | 252 | await run(() => { 253 | return this.store.loadRecord('post', serverPost.id); 254 | }); 255 | 256 | await run(() => { 257 | return this.store.loadRecord('post', serverPost.id, { backgroundReload: false }); 258 | }); 259 | 260 | assert.equal(serverCalls, 1); 261 | 262 | // wait 500ms and make sure there's no network request 263 | await new Promise(resolve => setTimeout(resolve, 500)); 264 | 265 | assert.equal(serverCalls, 1); 266 | }); 267 | 268 | }); 269 | -------------------------------------------------------------------------------- /tests/integration/mixins/loadable-store/load-records-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { settled, waitUntil } from '@ember/test-helpers'; 4 | import MirageServer from 'dummy/tests/integration/helpers/mirage-server'; 5 | import LoadableStore from 'ember-data-storefront/mixins/loadable-store'; 6 | 7 | module('Integration | Mixins | LoadableStore | loadRecords', function(hooks) { 8 | setupTest(hooks); 9 | 10 | hooks.beforeEach(function() { 11 | this.server = new MirageServer({ 12 | discoverEmberDataModels: true, 13 | baseConfig() { 14 | this.resource('posts'); 15 | } 16 | }); 17 | 18 | this.store = this.owner.lookup('service:store'); 19 | this.store.reopen(LoadableStore); 20 | this.store.resetCache(); 21 | }); 22 | 23 | hooks.afterEach(function() { 24 | this.server.shutdown(); 25 | }); 26 | 27 | test('it can load a collection', async function(assert) { 28 | let post = this.server.create('post'); 29 | 30 | let posts = await this.store.loadRecords('post'); 31 | 32 | assert.equal(posts.get('length'), 1); 33 | assert.equal(posts.get('firstObject.id'), post.id); 34 | }); 35 | 36 | test('it resolves immediately with an already-loaded collection, then reloads it in the background', async function(assert) { 37 | let serverPost = this.server.createList('post', 2); 38 | let serverCalls = 0; 39 | this.server.pretender.handledRequest = () => serverCalls++; 40 | 41 | let posts = await this.store.loadRecords('post', serverPost.id); 42 | 43 | assert.equal(serverCalls, 1); 44 | assert.equal(posts.get('length'), 2); 45 | 46 | this.server.create('post'); 47 | posts = await this.store.loadRecords('post', serverPost.id); 48 | 49 | assert.equal(serverCalls, 1); 50 | assert.equal(posts.get('length'), 2); 51 | 52 | await waitUntil(() => serverCalls === 2); 53 | await settled(); 54 | 55 | assert.equal(posts.get('length'), 3); 56 | }); 57 | 58 | test('it forces an already-loaded collection to fetch with the reload options', async function(assert) { 59 | this.server.createList('post', 3); 60 | let serverCalls = 0; 61 | this.server.pretender.handledRequest = function(method, url, request) { 62 | serverCalls++; 63 | 64 | // the reload qp should not be sent 65 | assert.ok(!request.queryParams.reload); 66 | }; 67 | 68 | await this.store.loadRecords('post', { reload: true }); 69 | let posts = await this.store.loadRecords('post', { reload: true }); 70 | 71 | assert.equal(serverCalls, 2); 72 | assert.equal(posts.get('length'), 3); 73 | }); 74 | 75 | test('it should not make a network request for an already loaded collection that has background reload false', async function(assert) { 76 | this.server.createList('post', 3); 77 | let serverCalls = 0; 78 | this.server.pretender.handledRequest = function(method, url, request) { 79 | serverCalls++; 80 | 81 | // the background reload qp should not be sent 82 | assert.ok(!request.queryParams.backgroundReload); 83 | }; 84 | 85 | await this.store.loadRecords('post'); 86 | await this.store.loadRecords('post', { backgroundReload: false }); 87 | 88 | assert.equal(serverCalls, 1); 89 | 90 | // wait 500ms and make sure there's no network request 91 | await new Promise(resolve => setTimeout(resolve, 500)); 92 | 93 | assert.equal(serverCalls, 1); 94 | }); 95 | 96 | test('it can load a collection with a query object', async function(assert) { 97 | let serverPosts = this.server.createList('post', 2); 98 | let serverCalls = []; 99 | this.server.pretender.handledRequest = (...args) => { 100 | serverCalls.push(args); 101 | }; 102 | 103 | let posts = await this.store.loadRecords('post', { 104 | filter: { 105 | testing: 123 106 | } 107 | }); 108 | 109 | assert.equal(posts.get('length'), 2); 110 | assert.equal(posts.get('firstObject.id'), serverPosts[0].id); 111 | assert.equal(serverCalls.length, 1); 112 | assert.deepEqual(serverCalls[0][2].queryParams, { "filter[testing]": "123" } ); 113 | }); 114 | 115 | test('it can load a collection with includes', async function(assert) { 116 | let serverPost = this.server.create('post', { 117 | comments: this.server.createList('comment', 2) 118 | }); 119 | let serverCalls = []; 120 | this.server.pretender.handledRequest = function() { 121 | serverCalls.push(arguments); 122 | }; 123 | 124 | let posts = await this.store.loadRecords('post', { 125 | include: 'comments' 126 | }); 127 | 128 | assert.equal(posts.get('length'), 1); 129 | assert.equal(posts.get('firstObject.id'), serverPost.id); 130 | assert.equal(posts.get('firstObject.comments.length'), 2); 131 | }); 132 | 133 | test('it can load a polymorphic collection with model-specific includes', async function(assert) { 134 | this.server.get('/homepage-items'); 135 | let post = this.server.create('post'); 136 | let comment = this.server.create('comment'); 137 | this.server.create('homepage-item', { itemizable: post }); 138 | this.server.create('homepage-item', { itemizable: comment }); 139 | 140 | await this.store.loadRecords('homepage-item', { include: 'itemizable.tags' }); 141 | 142 | assert.ok(this.store.peekRecord('post', post.id)); 143 | assert.ok(this.store.peekRecord('comment', comment.id)); 144 | }); 145 | 146 | module('Tracking includes', function() { 147 | test('it will track an include', async function(assert) { 148 | let serverPost = this.server.create('post', { title: 'My post' }); 149 | this.server.createList('comment', 3, { post: serverPost }); 150 | 151 | let posts = await this.store.loadRecords('post', { include: 'comments' }); 152 | 153 | assert.ok(posts.get('firstObject').hasLoaded('comments')); 154 | }); 155 | 156 | test('it will track a dot path include', async function(assert) { 157 | let serverPost = this.server.create('post', { title: 'My post' }); 158 | let serverComments = this.server.createList('comment', 3, { post: serverPost }); 159 | 160 | serverComments.forEach(comment => { 161 | this.server.create('author', { comments: [comment] }); 162 | }); 163 | 164 | let posts = await this.store.loadRecords('post', { include: 'comments.author' }); 165 | 166 | assert.ok(posts.get('firstObject').hasLoaded('comments.author')); 167 | }); 168 | 169 | test('it will track multiple includes', async function(assert) { 170 | let serverAuthor = this.server.create('author'); 171 | let serverPost = this.server.create('post', { 172 | title: 'My post', 173 | author: serverAuthor 174 | }); 175 | let serverComments = this.server.createList('comment', 3, { post: serverPost }); 176 | 177 | serverComments.forEach(comment => { 178 | this.server.create('author', { comments: [comment] }); 179 | }); 180 | 181 | let posts = await this.store.loadRecords('post', { include: 'author,comments.author' }); 182 | 183 | assert.ok(posts.get('firstObject').hasLoaded('author,comments.author')); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from '../app'; 2 | import config from '../config/environment'; 3 | import { setApplication } from '@ember/test-helpers'; 4 | import { start } from 'ember-qunit'; 5 | 6 | setApplication(Application.create(config.APP)); 7 | 8 | start(); 9 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embermap/ember-data-storefront/414d632d9928ec033bda84a986f0a45a57cebd5a/tests/unit/.gitkeep -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embermap/ember-data-storefront/414d632d9928ec033bda84a986f0a45a57cebd5a/vendor/.gitkeep --------------------------------------------------------------------------------