├── .editorconfig
├── .ember-cli.js
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── npm-publish.yml
├── .gitignore
├── .npmignore
├── .nvmrc
├── .prettierignore
├── .prettierrc.js
├── .template-lintrc.js
├── .watchmanconfig
├── BACKEND_INSTRUCTIONS.md
├── FRONTEND_INSTRUCTIONS.md
├── app
├── adapters
│ ├── application.js
│ ├── comment.js
│ ├── profile.js
│ └── user.js
├── app.js
├── components
│ ├── article-author.hbs
│ ├── article-form.hbs
│ ├── article-form.js
│ ├── article-list.hbs
│ ├── article-list.js
│ ├── article-meta.hbs
│ ├── article-meta.js
│ ├── article-preview.hbs
│ ├── article-preview.js
│ ├── comment-form.hbs
│ ├── comment-form.js
│ ├── comment.hbs
│ ├── comment.js
│ ├── comments-section.hbs
│ ├── comments-section.js
│ ├── favorite-article.hbs
│ ├── favorite-article.js
│ ├── follow-profile.hbs
│ ├── follow-profile.js
│ ├── footer.hbs
│ ├── login-form.hbs
│ ├── login-form.js
│ ├── nav.hbs
│ ├── nav.js
│ ├── pagination.hbs
│ ├── pagination.js
│ ├── profile.hbs
│ ├── profile.js
│ ├── register-form.hbs
│ ├── register-form.js
│ ├── settings-form.hbs
│ ├── settings-form.js
│ ├── tag-list.hbs
│ └── tag-list.js
├── controllers
│ └── index.js
├── helpers
│ └── format-date.js
├── index.html
├── models
│ ├── article.js
│ ├── comment.js
│ ├── profile.js
│ └── user.js
├── router.js
├── routes
│ ├── application.js
│ ├── articles
│ │ └── article.js
│ ├── editor.js
│ ├── editor
│ │ └── edit.js
│ ├── index.js
│ ├── logged-in.js
│ ├── profile.js
│ ├── profile
│ │ ├── favorites.js
│ │ └── index.js
│ ├── settings.js
│ └── sign-up.js
├── serializers
│ ├── application.js
│ ├── article.js
│ ├── comment.js
│ ├── profile.js
│ └── user.js
├── services
│ └── session.js
├── styles
│ └── app.css
└── templates
│ ├── application.hbs
│ ├── articles
│ └── article.hbs
│ ├── editor.hbs
│ ├── editor
│ ├── edit.hbs
│ └── index.hbs
│ ├── error.hbs
│ ├── index.hbs
│ ├── login.hbs
│ ├── profile
│ ├── favorites.hbs
│ └── index.hbs
│ ├── register.hbs
│ └── settings.hbs
├── config
├── deprecation-workflow.js
├── environment.js
├── optional-features.json
└── targets.js
├── ember-cli-build.js
├── jsconfig.json
├── logo-ember.png
├── logo.png
├── mirage
├── config.js
├── factories
│ ├── article.js
│ ├── author.js
│ ├── comment.js
│ ├── profile.js
│ └── user.js
├── models
│ ├── article.js
│ ├── author.js
│ ├── comment.js
│ ├── profile.js
│ ├── tag.js
│ └── user.js
├── scenarios
│ └── default.js
└── serializers
│ ├── application.js
│ ├── article.js
│ └── comment.js
├── package.json
├── postman
├── Conduit.json.postman_collection.json
└── api.postman.env.json
├── public
├── _redirects
├── assets
│ └── ember.ico
└── robots.txt
├── readme.md
├── testem.js
├── tests
├── acceptance
│ ├── article-test.js
│ ├── editor
│ │ ├── article-test.js
│ │ └── new-test.js
│ ├── error-test.js
│ ├── index-test.js
│ ├── login-test.js
│ ├── profile-test.js
│ ├── register-test.js
│ └── settings-test.js
├── helpers
│ └── user.js
├── index.html
├── integration
│ └── helpers
│ │ └── format-date-test.js
└── test-helper.js
└── yarn.lock
/.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.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | process.env.EMBER_VERSION = 'OCTANE';
4 |
5 | module.exports = {
6 | /**
7 | Ember CLI sends analytics information by default. The data is completely
8 | anonymous, but there are times when you might want to disable this behavior.
9 |
10 | Setting `disableAnalytics` to true will prevent any data from being sent.
11 | */
12 | disableAnalytics: false,
13 | };
14 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | # unconventional js
2 | /blueprints/*/files/
3 | /vendor/
4 | /mirage/mirage
5 |
6 | # compiled output
7 | /dist/
8 | /tmp/
9 |
10 | # dependencies
11 | /bower_components/
12 | /node_modules/
13 |
14 | # misc
15 | /coverage/
16 | !.*
17 | .eslintcache
18 |
19 | # ember-try
20 | /.node_modules.ember-try/
21 | /bower.json.ember-try
22 | /package.json.ember-try
23 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | root: true,
5 | parser: 'babel-eslint',
6 | parserOptions: {
7 | ecmaVersion: 2018,
8 | sourceType: 'module',
9 | ecmaFeatures: {
10 | legacyDecorators: true,
11 | },
12 | },
13 | plugins: ['ember', 'prettier'],
14 | extends: ['eslint:recommended', 'plugin:ember/recommended', 'plugin:prettier/recommended'],
15 | env: {
16 | browser: true,
17 | },
18 | rules: {},
19 | overrides: [
20 | // node files
21 | {
22 | files: [
23 | '.ember-cli.js',
24 | '.eslintrc.js',
25 | '.prettierrc.js',
26 | '.template-lintrc.js',
27 | 'ember-cli-build.js',
28 | 'testem.js',
29 | 'blueprints/*/index.js',
30 | 'config/**/*.js',
31 | 'lib/*/index.js',
32 | 'server/**/*.js',
33 | ],
34 | parserOptions: {
35 | sourceType: 'script',
36 | },
37 | env: {
38 | browser: false,
39 | node: true,
40 | },
41 | plugins: ['node'],
42 | extends: ['plugin:node/recommended'],
43 | rules: {
44 | // this can be removed once the following is fixed
45 | // https://github.com/mysticatea/eslint-plugin-node/issues/77
46 | 'node/no-unpublished-require': 'off',
47 | },
48 | },
49 | ],
50 | };
51 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3 |
4 | name: Node.js Package
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 16
18 | - run: npm ci
19 | - run: npm test
20 |
21 | publish-npm:
22 | needs: build
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v3
26 | - uses: actions/setup-node@v3
27 | with:
28 | node-version: 16
29 | registry-url: https://registry.npmjs.org/
30 | - run: npm ci
31 | - run: npm publish
32 | env:
33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}}
34 |
--------------------------------------------------------------------------------
/.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 | /.sass-cache
13 | /.eslintcache
14 | /connect.lock
15 | /coverage/
16 | /libpeerconnection.log
17 | /npm-debug.log*
18 | /testem.log
19 | /yarn-error.log
20 | /test-results
21 | .vscode
22 |
23 | # ember-try
24 | /.node_modules.ember-try/
25 | /bower.json.ember-try
26 | /package.json.ember-try
27 |
--------------------------------------------------------------------------------
/.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 | /.gitignore
16 | /.template-lintrc.js
17 | /.watchmanconfig
18 | /bower.json
19 | /config/ember-try.js
20 | /CONTRIBUTING.md
21 | /ember-cli-build.js
22 | /testem.js
23 | /tests/
24 | /yarn.lock
25 | .gitkeep
26 |
27 | # ember-try
28 | /.node_modules.ember-try/
29 | /bower.json.ember-try
30 | /package.json.ember-try
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12.16.3
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
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 | .eslintcache
17 |
18 | # ember-try
19 | /.node_modules.ember-try/
20 | /bower.json.ember-try
21 | /package.json.ember-try
22 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | printWidth: 100,
5 | singleQuote: true,
6 | trailingComma: 'all',
7 | };
8 |
--------------------------------------------------------------------------------
/.template-lintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | extends: 'octane',
5 | };
6 |
--------------------------------------------------------------------------------
/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {
2 | "ignore_dirs": ["tmp", "dist"]
3 | }
4 |
--------------------------------------------------------------------------------
/BACKEND_INSTRUCTIONS.md:
--------------------------------------------------------------------------------
1 | > *Note: Delete this file before publishing your app!*
2 |
3 | # [Backend API spec](https://github.com/gothinkster/realworld/tree/master/api)
4 |
5 | For your convenience, we have a [Postman collection](https://github.com/gothinkster/realworld/blob/master/api/Conduit.json.postman_collection) that you can use to test your API endpoints as you build your app.
6 |
--------------------------------------------------------------------------------
/FRONTEND_INSTRUCTIONS.md:
--------------------------------------------------------------------------------
1 | > *Note: Delete this file before publishing your app!*
2 |
3 | ### Using the hosted API
4 |
5 | Simply point your [API requests](../api/) to `https://conduit.productionready.io/api` and you're good to go!
6 |
7 | ### Routing Guidelines
8 |
9 | - Home page (URL: /#/ )
10 | - List of tags
11 | - List of articles pulled from either Feed, Global, or by Tag
12 | - Pagination for list of articles
13 | - Sign in/Sign up pages (URL: /#/login, /#/register )
14 | - Uses JWT (store the token in localStorage)
15 | - Authentication can be easily switched to session/cookie based
16 | - Settings page (URL: /#/settings )
17 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
18 | - Article page (URL: /#/article/article-slug-here )
19 | - Delete article button (only shown to article's author)
20 | - Render markdown from server client side
21 | - Comments section at bottom of page
22 | - Delete comment button (only shown to comment's author)
23 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites )
24 | - Show basic user info
25 | - List of articles populated from author's created articles or author's favorited articles
26 |
27 | # Styles
28 |
29 | Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](#header) does this by default):
30 |
31 | ```html
32 |
33 | ```
34 |
35 | Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template).
36 |
37 |
38 | # Templates
39 |
40 | - [Layout](#layout)
41 | - [Header](#header)
42 | - [Footer](#footer)
43 | - [Pages](#pages)
44 | - [Home](#home)
45 | - [Login/Register](#loginregister)
46 | - [Profile](#profile)
47 | - [Settings](#settings)
48 | - [Create/Edit Article](#createedit-article)
49 | - [Article](#article)
50 |
51 |
52 | ## Layout
53 |
54 |
55 | ### Header
56 |
57 | ```html
58 |
59 |
60 |
61 |
62 | Conduit
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
93 | ```
94 |
95 | ### Footer
96 | ```html
97 |
105 |
106 |
107 |
108 | ```
109 |
110 | ## Pages
111 |
112 | ### Home
113 | ```html
114 |
115 |
116 |
117 |
118 |
conduit
119 |
A place to share your knowledge.
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
137 |
138 |
155 |
156 |
173 |
174 |
175 |
176 |
177 |
190 |
191 |
192 |
193 |
194 |
195 |
196 | ```
197 |
198 | ### Login/Register
199 |
200 | ```html
201 |
202 |
203 |
204 |
205 |
206 |
Sign up
207 |
208 | Have an account?
209 |
210 |
211 |
212 | - That email is already taken
213 |
214 |
215 |
229 |
230 |
231 |
232 |
233 |
234 | ```
235 |
236 | ### Profile
237 |
238 | ```html
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |

247 |
Eric Simons
248 |
249 | Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games
250 |
251 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
276 |
277 |
294 |
295 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 | ```
321 |
322 | ### Settings
323 |
324 | ```html
325 |
326 |
327 |
328 |
329 |
330 |
Your Settings
331 |
332 |
352 |
353 |
354 |
355 |
356 |
357 | ```
358 |
359 | ### Create/Edit Article
360 |
361 | ```html
362 |
391 |
392 |
393 | <%= footer %>
394 | ```
395 |
396 | ### Article
397 |
398 | ```html
399 |
400 |
401 |
402 |
403 |
404 |
How to build webapps that scale
405 |
406 |
407 |

408 |
412 |
417 |
418 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 | Web development technologies have evolved at an incredible clip over the past few years.
434 |
435 |
Introducing RealWorld.
436 |
It's a great solution for learning how other frameworks work.
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |

445 |
449 |
450 |
455 |
456 |
461 |
462 |
463 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
With supporting text below as a natural lead-in to additional content.
471 |
472 |
480 |
481 |
482 |
483 |
484 |
With supporting text below as a natural lead-in to additional content.
485 |
486 |
498 |
499 |
500 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 | ```
520 |
--------------------------------------------------------------------------------
/app/adapters/application.js:
--------------------------------------------------------------------------------
1 | import { errorsHashToArray } from '@ember-data/adapter/error';
2 | import RESTAdapter from '@ember-data/adapter/rest';
3 | import { inject as service } from '@ember/service';
4 | import ENV from 'ember-realworld/config/environment';
5 |
6 | export default class ApplicationAdapter extends RESTAdapter {
7 | @service session;
8 |
9 | host = ENV.APP.apiHost;
10 |
11 | headers = {
12 | Authorization: this.session.token ? `Token ${this.session.token}` : '',
13 | };
14 |
15 | handleResponse(status, headers, payload) {
16 | if (this.isInvalid(...arguments)) {
17 | if (typeof payload === 'string') {
18 | payload = JSON.parse(payload);
19 | }
20 | payload.errors = errorsHashToArray(payload.errors);
21 | }
22 |
23 | return super.handleResponse(status, headers, payload);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/adapters/comment.js:
--------------------------------------------------------------------------------
1 | import ApplicationAdapter from './application';
2 |
3 | export default class AuthorAdapter extends ApplicationAdapter {
4 | endpoint(id) {
5 | return `${this.host}/articles/${id}/comments`;
6 | }
7 |
8 | urlForCreateRecord(modelName, snapshot) {
9 | return this.endpoint(snapshot.record.article.content.id);
10 | }
11 |
12 | urlForDeleteRecord(id, modelName, snapshot) {
13 | return `${this.endpoint(snapshot.record.article.content.id)}/${id}`;
14 | }
15 |
16 | urlForQuery(query) {
17 | return this.endpoint(query.article_id);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/adapters/profile.js:
--------------------------------------------------------------------------------
1 | import ApplicationAdapter from './application';
2 |
3 | export default class UserAdapter extends ApplicationAdapter {
4 | pathForType() {
5 | return 'profiles';
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/adapters/user.js:
--------------------------------------------------------------------------------
1 | import ApplicationAdapter from './application';
2 | import ENV from 'ember-realworld/config/environment';
3 |
4 | export default class UserAdapter extends ApplicationAdapter {
5 | urlForUpdateRecord() {
6 | return `${ENV.APP.apiHost}/user`;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/app/app.js:
--------------------------------------------------------------------------------
1 | import Application from '@ember/application';
2 | import Resolver from 'ember-resolver';
3 | import loadInitializers from 'ember-load-initializers';
4 | import config from 'ember-realworld/config/environment';
5 |
6 | export default class App extends Application {
7 | modulePrefix = config.modulePrefix;
8 | podModulePrefix = config.podModulePrefix;
9 | Resolver = Resolver;
10 | }
11 |
12 | loadInitializers(App, config.modulePrefix);
13 |
--------------------------------------------------------------------------------
/app/components/article-author.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{@author.id}}
4 | {{format-date @updatedAt}}
5 |
--------------------------------------------------------------------------------
/app/components/article-form.hbs:
--------------------------------------------------------------------------------
1 | {{#unless this.article.isValid}}
2 |
3 | {{#each this.article.errors as |error|}}
4 | - {{error.attribute}} {{error.message}}
5 | {{/each}}
6 |
7 | {{/unless}}
8 |
--------------------------------------------------------------------------------
/app/components/article-form.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { action } from '@ember/object';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class ArticleFormComponent extends Component {
7 | @service store;
8 | @service router;
9 |
10 | @tracked article = null;
11 | @tracked rawTagList = '';
12 |
13 | constructor() {
14 | super(...arguments);
15 | if (this.args.article) {
16 | this.article = this.args.article;
17 | this.rawTagList = this.article.tagList.join(' ');
18 | } else {
19 | this.article = this.store.createRecord('article');
20 | }
21 | }
22 |
23 | willDestroy() {
24 | super.willDestroy(...arguments);
25 | if (this.article.isNew) {
26 | this.store.unloadRecord(this.article);
27 | } else if (this.article.hasDirtyAttributes) {
28 | if (
29 | window.confirm("You haven't saved your changes. Are you sure you want to leave the page?")
30 | ) {
31 | this.article.rollbackAttributes();
32 | }
33 | }
34 | }
35 |
36 | get buttonIsDisabled() {
37 | return !this.article.hasDirtyAttributes || this.article.isSaving;
38 | }
39 |
40 | @action
41 | async processTags(e) {
42 | e.preventDefault();
43 | let tags = this.rawTagList.split(' ');
44 |
45 | // TODO - I'd like to do the following but it doesn't work and I need to use #set. why?
46 | // this.article.tagList = tags;
47 |
48 | this.article.set('tagList', tags);
49 | }
50 |
51 | @action
52 | async publishArticle() {
53 | try {
54 | await this.article.save();
55 | this.router.transitionTo('articles.article', this.article);
56 | } catch {
57 | // Catch article validation exceptions
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/components/article-list.hbs:
--------------------------------------------------------------------------------
1 |
2 | {{#if this.loadArticles.isRunning}}
3 |
Loading...
4 | {{else}}
5 | {{#each this.articles as |article|}}
6 |
7 | {{else}}
8 |
No articles are here... yet.
9 | {{/each}}
10 | {{#if this.articles}}
11 |
13 | {{/if}}
14 | {{/if}}
15 |
--------------------------------------------------------------------------------
/app/components/article-list.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { task } from 'ember-concurrency-decorators';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class ArticleListComponent extends Component {
7 | @service session;
8 | @service store;
9 | @tracked articles = [];
10 |
11 | constructor() {
12 | super(...arguments);
13 | this.loadArticles.perform();
14 | }
15 |
16 | @task({ restartable: true })
17 | *loadArticles() {
18 | let NUMBER_OF_ARTICLES = 10;
19 | let offset = (parseInt(this.args.page, 10) - 1) * NUMBER_OF_ARTICLES;
20 | if (this.args.feed === 'your') {
21 | this.articles = yield this.session.user.fetchFeed(this.args.page);
22 | } else {
23 | this.articles = yield this.store.query('article', {
24 | limit: NUMBER_OF_ARTICLES,
25 | offset,
26 | tag: this.args.tag,
27 | });
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/components/article-meta.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{#if (eq this.session.user.username @article.author.id)}}
4 |
6 | Edit Article
7 |
8 |
12 | {{else}}
13 |
14 |
15 |
16 | {{/if}}
17 |
--------------------------------------------------------------------------------
/app/components/article-meta.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class SignUpComponent extends Component {
6 | @service session;
7 | @service router;
8 |
9 | @action
10 | async deleteArticle() {
11 | await this.args.article.destroyRecord();
12 | this.router.transitionTo('index');
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/app/components/article-preview.hbs:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | {{@article.title}}
8 | {{@article.description}}
9 | Read more...
10 | {{#if @article.tagList}}
11 |
12 | {{#each @article.tagList as |tag|}}
13 | -
14 | {{tag}}
15 |
16 | {{/each}}
17 |
18 | {{/if}}
19 |
20 |
--------------------------------------------------------------------------------
/app/components/article-preview.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class ArticlePreviewComponent extends Component {
6 | @service session;
7 | @service router;
8 |
9 | @action
10 | favoriteArticle(article, operation) {
11 | if (this.session.isLoggedIn) {
12 | article[operation]();
13 | } else {
14 | this.router.transitionTo('login');
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/comment-form.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/comment-form.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { action } from '@ember/object';
3 | import { tracked } from '@glimmer/tracking';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class CommentsFormComponent extends Component {
7 | @service session;
8 |
9 | @tracked
10 | body = '';
11 |
12 | @action
13 | async addComment(e) {
14 | e.preventDefault();
15 | this.args.addComment(this.body);
16 | this.body = '';
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/components/comment.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{@comment.body}}
4 |
5 |
17 |
--------------------------------------------------------------------------------
/app/components/comment.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class CommentComponent extends Component {
6 | @service session;
7 |
8 | @action
9 | async deleteComment(e) {
10 | e.preventDefault();
11 | await this.args.comment.destroyRecord();
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/components/comments-section.hbs:
--------------------------------------------------------------------------------
1 | {{#if this.session.isLoggedIn}}
2 |
3 | {{/if}}
4 |
5 | {{#if this.isLoading}}
6 | Loading comments...
7 | {{else}}
8 | {{#each @article.comments as |comment|}}
9 |
10 | {{/each}}
11 | {{/if}}
--------------------------------------------------------------------------------
/app/components/comments-section.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { action } from '@ember/object';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class CommentsSectionComponent extends Component {
7 | @tracked isLoading = false;
8 |
9 | @service store;
10 | @service session;
11 |
12 | constructor() {
13 | super(...arguments);
14 | this.loadComments();
15 | }
16 |
17 | async loadComments() {
18 | this.isLoading = true;
19 | let comments = await this.args.article.loadComments();
20 | this.args.article.set('comments', comments);
21 | this.isLoading = false;
22 | }
23 |
24 | @action
25 | async addComment(body) {
26 | try {
27 | await this.store.createRecord('comment', { article: this.args.article, body }).save();
28 | } catch {
29 | // Capture comment save error
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/components/favorite-article.hbs:
--------------------------------------------------------------------------------
1 | article favorited: {{@article.favorited}}
2 |
--------------------------------------------------------------------------------
/app/components/favorite-article.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class FavoriteArticleComponent extends Component {
6 | @service session;
7 | @service router;
8 |
9 | @action
10 | favoriteArticle(operation) {
11 | if (this.session.isLoggedIn) {
12 | this.args.article[operation]();
13 | } else {
14 | this.router.transitionTo('login');
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/follow-profile.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/follow-profile.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class FollowProfileComponent extends Component {
6 | @service session;
7 | @service router;
8 |
9 | @action
10 | followProfile(operation) {
11 | if (this.session.isLoggedIn) {
12 | // TODO - We do this because in one area `profile` is from a `belongsTo`, and from the model hook in another. Not sure if there's a better way to do this to access the props.
13 | let profile = this.args.profile.content || this.args.profile;
14 | profile[operation]();
15 | } else {
16 | this.router.transitionTo('login');
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/components/footer.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/login-form.hbs:
--------------------------------------------------------------------------------
1 | Sign in
2 |
3 | Need an account?
4 |
5 | {{#if this.loginErrors}}
6 |
7 | {{#each this.loginErrors as |error|}}
8 | - {{error}}
9 | {{/each}}
10 |
11 | {{/if}}
12 |
--------------------------------------------------------------------------------
/app/components/login-form.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { inject as service } from '@ember/service';
4 | import { action } from '@ember/object';
5 |
6 | export default class LoginFormComponent extends Component {
7 | @tracked email = '';
8 | @tracked password = '';
9 | @tracked user = null;
10 | @tracked loginErrors = [];
11 |
12 | @service session;
13 | @service router;
14 |
15 | @action
16 | async submit(e) {
17 | e.preventDefault();
18 | this.loginErrors = [];
19 | this.user = await this.session.logIn(this.email, this.password);
20 | if (this.user.errors.length) {
21 | this.loginErrors = this.user.errors;
22 | } else {
23 | this.router.transitionTo('index');
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/components/nav.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/nav.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class SignUpComponent extends Component {
5 | @service session;
6 | }
7 |
--------------------------------------------------------------------------------
/app/components/pagination.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/pagination.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 |
3 | export default class PaginationComponent extends Component {
4 | get pages() {
5 | if (!this.args.total) {
6 | return [];
7 | }
8 | let pages = Math.ceil(this.args.total / this.args.perPage);
9 | return Array.from(Array(pages).keys()).map((_, index) => index + 1);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/components/profile.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |

7 |
{{@profile.id}}
8 |
{{@profile.bio}}
9 | {{#if (eq @profile.id this.session.user.username)}}
10 |
11 | Edit Profile Settings
12 |
13 | {{else}}
14 |
15 | {{/if}}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | -
26 | My Articles
28 |
29 | -
30 | Favorited
31 | Articles
32 |
33 |
34 |
35 | {{#each @articles as |article|}}
36 |
37 | {{/each}}
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/app/components/profile.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class CommentComponent extends Component {
5 | @service session;
6 | }
7 |
--------------------------------------------------------------------------------
/app/components/register-form.hbs:
--------------------------------------------------------------------------------
1 | Sign up
2 |
3 | Have an account?
4 |
5 | {{#unless this.user.isValid}}
6 |
7 | {{#each this.user.errors as |error|}}
8 | - {{error.attribute}} {{error.message}}
9 | {{/each}}
10 |
11 | {{/unless}}
12 |
--------------------------------------------------------------------------------
/app/components/register-form.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { inject as service } from '@ember/service';
4 | import { action } from '@ember/object';
5 |
6 | export default class RegisterFormComponent extends Component {
7 | @tracked username = '';
8 | @tracked email = '';
9 | @tracked password = '';
10 | @tracked user = null;
11 |
12 | @service session;
13 | @service router;
14 |
15 | @action
16 | async submit(e) {
17 | e.preventDefault();
18 | this.user = await this.session.register(this.username, this.email, this.password);
19 | if (this.user.isValid) {
20 | this.router.transitionTo('index');
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/components/settings-form.hbs:
--------------------------------------------------------------------------------
1 | Your Settings
2 | {{#unless this.session.user.isValid}}
3 |
4 | {{#each this.session.user.errors as |error index|}}
5 | - {{error.attribute}} {{error.message}}
6 | {{/each}}
7 |
8 | {{/unless}}
9 |
--------------------------------------------------------------------------------
/app/components/settings-form.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { inject as service } from '@ember/service';
3 | import { action } from '@ember/object';
4 |
5 | export default class SettingsFormComponent extends Component {
6 | @service session;
7 |
8 | willDestroy() {
9 | super.willDestroy(...arguments);
10 | if (this.session.user.hasDirtyAttributes) {
11 | this.session.user.rollbackAttributes();
12 | }
13 | }
14 |
15 | @action
16 | async submit(e) {
17 | e.preventDefault();
18 | try {
19 | await this.session.user.save();
20 | } catch {
21 | // Catch any save errors
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/components/tag-list.hbs:
--------------------------------------------------------------------------------
1 | Popular Tags
2 | {{#if this.loadTags.isRunning}}
3 | Loading...
4 | {{else}}
5 |
6 | {{#each this.tags as |tag| }}
7 | {{tag}}
9 | {{/each}}
10 |
11 | {{/if}}
--------------------------------------------------------------------------------
/app/components/tag-list.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { inject as service } from '@ember/service';
4 | import { task } from 'ember-concurrency-decorators';
5 |
6 | export default class TagListComponent extends Component {
7 | @service session;
8 | @tracked tags = [];
9 |
10 | constructor() {
11 | super(...arguments);
12 | this.loadTags.perform();
13 | }
14 |
15 | @task({ restartable: true })
16 | *loadTags() {
17 | let { tags } = yield this.session.fetch('/tags');
18 | this.tags = tags;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/controllers/index.js:
--------------------------------------------------------------------------------
1 | import Controller from '@ember/controller';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class IndexController extends Controller {
5 | @service session;
6 | @service router;
7 |
8 | queryParams = ['tag', 'feed', 'page'];
9 | tag = null;
10 | feed = null;
11 | page = 1;
12 | }
13 |
--------------------------------------------------------------------------------
/app/helpers/format-date.js:
--------------------------------------------------------------------------------
1 | import { helper } from '@ember/component/helper';
2 |
3 | export function formatDate([date]) {
4 | if (!date) {
5 | return '';
6 | }
7 | return new Date(date).toLocaleDateString('en-US', {
8 | month: 'long',
9 | day: 'numeric',
10 | year: 'numeric',
11 | });
12 | }
13 |
14 | export default helper(formatDate);
15 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Conduit
7 |
8 |
9 |
10 | {{content-for "head"}}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{content-for "head-footer"}}
20 |
21 |
22 | {{content-for "body"}}
23 |
24 |
25 |
26 |
27 | {{content-for "body-footer"}}
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/models/article.js:
--------------------------------------------------------------------------------
1 | import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
2 | import marked from 'marked';
3 | import { htmlSafe } from '@ember/string';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class ArticleModel extends Model {
7 | @service session;
8 |
9 | @attr title;
10 | @attr description;
11 | @attr body;
12 | @attr('date') createdAt;
13 | @attr('date') updatedAt;
14 | @attr favorited;
15 | @attr favoritesCount;
16 | @attr({ defaultValue: () => [] }) tagList;
17 |
18 | @belongsTo('profile') author;
19 | @hasMany('comment', { async: false }) comments;
20 |
21 | get safeMarkup() {
22 | let markup = marked(this.body, { sanitize: true });
23 | return htmlSafe(markup);
24 | }
25 |
26 | loadComments() {
27 | return this.store.query('comment', {
28 | article_id: this.id,
29 | });
30 | }
31 |
32 | async favorite() {
33 | await this.favoriteOperation('favorite');
34 | }
35 |
36 | async unfavorite() {
37 | await this.favoriteOperation('unfavorite');
38 | }
39 |
40 | async favoriteOperation(operation) {
41 | let { article } = await this.session.fetch(
42 | `/articles/${this.id}/favorite`,
43 | operation === 'unfavorite' ? 'DELETE' : 'POST',
44 | );
45 | this.store.pushPayload({
46 | articles: [Object.assign(article, { id: article.slug })],
47 | });
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/models/comment.js:
--------------------------------------------------------------------------------
1 | import Model, { attr, belongsTo } from '@ember-data/model';
2 |
3 | export default class CommentModel extends Model {
4 | @attr body;
5 | @attr('date') createdAt;
6 | @attr('date') updatedAt;
7 |
8 | @belongsTo('profile') author;
9 | @belongsTo('article') article;
10 | }
11 |
--------------------------------------------------------------------------------
/app/models/profile.js:
--------------------------------------------------------------------------------
1 | import Model, { attr, hasMany } from '@ember-data/model';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class UserModel extends Model {
5 | @service session;
6 |
7 | @attr bio;
8 | @attr image;
9 | @attr following;
10 |
11 | @hasMany('article', { async: false, inverse: 'author' }) articles;
12 |
13 | async loadArticles() {
14 | let articles = await this.store.query('article', { author: this.id });
15 | this.articles = articles;
16 | }
17 |
18 | fetchFavorites() {
19 | return this.store.query('article', { favorited: this.id });
20 | }
21 |
22 | async follow() {
23 | await this.followOperation('follow');
24 | }
25 |
26 | async unfollow() {
27 | await this.followOperation('unfollow');
28 | }
29 |
30 | async followOperation(operation) {
31 | let { profile } = await this.session.fetch(
32 | `/profiles/${this.id}/follow`,
33 | operation === 'follow' ? 'POST' : 'DELETE',
34 | );
35 | this.store.pushPayload({
36 | profiles: [Object.assign(profile, { id: profile.username })],
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/models/user.js:
--------------------------------------------------------------------------------
1 | import Model, { attr } from '@ember-data/model';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class UserModel extends Model {
5 | @service session;
6 |
7 | @attr bio;
8 | @attr email;
9 | @attr image;
10 | @attr password;
11 | @attr token;
12 | @attr username;
13 | @attr('date') createdAt;
14 | @attr('date') updatedAt;
15 |
16 | async fetchFeed(page = 1) {
17 | let { articles } = await this.session.fetch(`/articles/feed?page=${page}`);
18 | if (!articles.length) {
19 | return [];
20 | }
21 | let ids = articles.map((article) => article.slug);
22 | let normalizedArticles = articles.map((article) =>
23 | Object.assign({}, article, { id: article.slug }),
24 | );
25 | this.store.pushPayload({ articles: normalizedArticles });
26 | return this.store.peekAll('article').filter((article) => ids.includes(article.id));
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/router.js:
--------------------------------------------------------------------------------
1 | import EmberRouter from '@ember/routing/router';
2 | import config from 'ember-realworld/config/environment';
3 |
4 | export default class Router extends EmberRouter {
5 | location = config.locationType;
6 | rootURL = config.rootURL;
7 | }
8 |
9 | Router.map(function () {
10 | this.route('editor', function () {
11 | this.route('edit', { path: ':id' });
12 | });
13 | this.route('settings');
14 | this.route('register');
15 | this.route('login');
16 |
17 | this.route('articles', function () {
18 | this.route('article', { path: ':id' });
19 | });
20 | this.route('profile', { path: 'profile/:id' }, function () {
21 | this.route('favorites');
22 | });
23 | this.route('error', { path: '/*path' });
24 | });
25 |
--------------------------------------------------------------------------------
/app/routes/application.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class ApplicationRoute extends Route {
5 | @service session;
6 |
7 | model() {
8 | return this.session.initSession();
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/routes/articles/article.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class ArticlesArticleRoute extends Route {
4 | model({ id }) {
5 | return this.store.findRecord('article', id);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/editor.js:
--------------------------------------------------------------------------------
1 | import LoggedInRoute from 'ember-realworld/routes/logged-in';
2 |
3 | export default class EditorRoute extends LoggedInRoute {}
4 |
--------------------------------------------------------------------------------
/app/routes/editor/edit.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class EditorEditRoute extends Route {
4 | model({ id }) {
5 | return this.store.findRecord('article', id);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/index.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class IndexRoute extends Route {
4 | queryParams = {
5 | feed: {
6 | refreshModel: true,
7 | },
8 | page: {
9 | refreshModel: true,
10 | },
11 | tag: {
12 | refreshModel: true,
13 | },
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/app/routes/logged-in.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 | import { inject as service } from '@ember/service';
3 |
4 | export default class LoggedInRoute extends Route {
5 | @service session;
6 |
7 | beforeModel() {
8 | if (!this.session.isLoggedIn) {
9 | this.transitionTo('login');
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/routes/profile.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class ProfileRoute extends Route {
4 | model({ id }) {
5 | return this.store.findRecord('profile', id);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/profile/favorites.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class ProfileFavoritesRoute extends Route {
4 | async model() {
5 | let profile = this.modelFor('profile');
6 | return profile.fetchFavorites();
7 | }
8 |
9 | setupController(controller, model) {
10 | super.setupController(controller, model);
11 | controller.set('profile', this.modelFor('profile'));
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/routes/profile/index.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class ProfileIndexRoute extends Route {
4 | async model() {
5 | let profile = this.modelFor('profile');
6 | await profile.loadArticles();
7 | return profile;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/app/routes/settings.js:
--------------------------------------------------------------------------------
1 | import LoggedInRoute from 'ember-realworld/routes/logged-in';
2 |
3 | export default class SettingsRoute extends LoggedInRoute {}
4 |
--------------------------------------------------------------------------------
/app/routes/sign-up.js:
--------------------------------------------------------------------------------
1 | import Route from '@ember/routing/route';
2 |
3 | export default class SignUpRoute extends Route {}
4 |
--------------------------------------------------------------------------------
/app/serializers/application.js:
--------------------------------------------------------------------------------
1 | import RESTSerializer from '@ember-data/serializer/rest';
2 |
3 | export default class ApplicationSerializer extends RESTSerializer {}
4 |
--------------------------------------------------------------------------------
/app/serializers/article.js:
--------------------------------------------------------------------------------
1 | import RESTSerializer, { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
2 |
3 | export default class ArticleSerializer extends RESTSerializer.extend(EmbeddedRecordsMixin) {
4 | primaryKey = 'slug';
5 |
6 | attrs = {
7 | author: { embedded: 'always' },
8 | };
9 |
10 | extractMeta(store, typeClass, payload) {
11 | if (payload && payload.articlesCount) {
12 | let meta = { articlesCount: payload.articlesCount };
13 | delete payload.articlesCount;
14 | return meta;
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/serializers/comment.js:
--------------------------------------------------------------------------------
1 | import RESTSerializer, { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
2 |
3 | export default class CommentSerializer extends RESTSerializer.extend(EmbeddedRecordsMixin) {
4 | attrs = {
5 | author: { embedded: 'always' },
6 | };
7 | }
8 |
--------------------------------------------------------------------------------
/app/serializers/profile.js:
--------------------------------------------------------------------------------
1 | import ApplicationSerializer from './application';
2 |
3 | export default class UserSerializer extends ApplicationSerializer {
4 | primaryKey = 'username';
5 |
6 | normalizeFindRecordResponse(store, primaryModelClass, payload) {
7 | payload.profiles = payload.profile;
8 | delete payload.profile;
9 | return super.normalizeFindRecordResponse(...arguments);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/serializers/user.js:
--------------------------------------------------------------------------------
1 | import ApplicationSerializer from './application';
2 |
3 | export default class UserSerializer extends ApplicationSerializer {
4 | attrs = {
5 | token: {
6 | serialize: false,
7 | },
8 | createdAt: {
9 | serialize: false,
10 | },
11 | updatedAt: {
12 | serialize: false,
13 | },
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/app/services/session.js:
--------------------------------------------------------------------------------
1 | import Service from '@ember/service';
2 | import { action } from '@ember/object';
3 | import { inject as service } from '@ember/service';
4 | import { tracked } from '@glimmer/tracking';
5 | import ENV from 'ember-realworld/config/environment';
6 |
7 | export default class SessionService extends Service {
8 | @service store;
9 | @service session;
10 |
11 | @tracked token = null;
12 | @tracked user = null;
13 | static STORAGE_KEY = 'realworld.ember.token';
14 |
15 | initSession() {
16 | let storedToken = this.getStoredToken();
17 | if (storedToken) {
18 | this.token = storedToken;
19 | return this.fetchUser();
20 | }
21 | }
22 |
23 | get isLoggedIn() {
24 | return !!this.token;
25 | }
26 |
27 | async fetch(url, method = 'GET') {
28 | let response = await fetch(`${ENV.APP.apiHost}${url}`, {
29 | method,
30 | headers: {
31 | Authorization: this.token ? `Token ${this.token}` : '',
32 | },
33 | });
34 | let payload = await response.json();
35 | return payload;
36 | }
37 |
38 | @action
39 | async register(username, email, password) {
40 | let user = this.store.createRecord('user', {
41 | username,
42 | email,
43 | password,
44 | });
45 | try {
46 | await user.save();
47 | this.setToken(user.token);
48 | } catch (e) {
49 | // eslint-disable-next-line no-console
50 | console.error(e);
51 | } finally {
52 | this.user = user;
53 | }
54 | return user;
55 | }
56 |
57 | @action
58 | async logIn(email, password) {
59 | // @patocallaghan - It would be nice to encapsulate some of this logic in the User model as a `static` class, but unsure how to access container and store from there
60 | let login = await fetch(`${ENV.APP.apiHost}/users/login`, {
61 | method: 'POST',
62 | headers: {
63 | 'Content-Type': 'application/json',
64 | },
65 | body: JSON.stringify({
66 | user: {
67 | email,
68 | password,
69 | },
70 | }),
71 | });
72 | let userPayload = await login.json();
73 | if (userPayload.errors) {
74 | let errors = this.processLoginErrors(userPayload.errors);
75 | return { errors };
76 | } else {
77 | this.store.pushPayload({
78 | users: [userPayload.user],
79 | });
80 | this.setToken(userPayload.user.token);
81 | this.user = this.store.peekRecord('user', userPayload.user.id);
82 | return this.user;
83 | }
84 | }
85 |
86 | @action
87 | logOut() {
88 | this.removeToken();
89 | }
90 |
91 | async fetchUser() {
92 | let { user } = await this.session.fetch('/user');
93 | this.store.pushPayload({
94 | users: [user],
95 | });
96 | this.user = this.store.peekRecord('user', user.id);
97 | return this.user;
98 | }
99 |
100 | getStoredToken() {
101 | return localStorage.getItem(SessionService.STORAGE_KEY);
102 | }
103 |
104 | setToken(token) {
105 | this.token = token;
106 | localStorage.setItem(SessionService.STORAGE_KEY, token);
107 | }
108 |
109 | removeToken() {
110 | this.token = null;
111 | localStorage.removeItem('realworld.ember.token');
112 | }
113 |
114 | processLoginErrors(errors) {
115 | let loginErrors = [];
116 | let errorKeys = Object.keys(errors);
117 | errorKeys.forEach((attribute) => {
118 | errors[attribute].forEach((message) => {
119 | loginErrors.push(`${attribute} ${message}`);
120 | });
121 | });
122 | return loginErrors;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/app/styles/app.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/firstninja111/Simple-Ember-JS-DEMO/bb9f55e3a141c4723d09abc264281c8f97d41d87/app/styles/app.css
--------------------------------------------------------------------------------
/app/templates/application.hbs:
--------------------------------------------------------------------------------
1 |
2 | {{outlet}}
3 |
--------------------------------------------------------------------------------
/app/templates/articles/article.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{this.model.title}}
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{this.model.safeMarkup}}
12 |
13 |
14 |
15 |
18 |
23 |
24 |
--------------------------------------------------------------------------------
/app/templates/editor.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{outlet}}
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/templates/editor/edit.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/templates/editor/index.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/templates/error.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Sorry but that page does not exist.
6 |
Try find your page from the homepage
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/templates/index.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
conduit
5 |
A place to share your knowledge.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{#if this.session.isLoggedIn}}
14 | -
15 |
17 | Your Feed
18 |
19 | {{/if}}
20 | -
21 | {{#let (and (not this.tag) (not this.feed)) as |isGlobalFeed|}}
22 |
24 | Global Feed
25 |
26 | {{/let}}
27 |
28 | {{#if this.tag}}
29 | -
30 |
31 | #{{this.tag}}
32 |
33 | {{/if}}
34 |
35 |
36 |
37 |
38 |
39 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/templates/login.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/templates/profile/favorites.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/templates/profile/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/templates/register.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/templates/settings.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/deprecation-workflow.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | window.deprecationWorkflow = window.deprecationWorkflow || {};
3 | window.deprecationWorkflow.config = {
4 | workflow: [
5 | { handler: 'silence', matchId: 'ember-inflector.globals' },
6 | { handler: 'silence', matchId: 'ember-metal.get-with-default' },
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/config/environment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function (environment) {
4 | const ENV = {
5 | modulePrefix: 'ember-realworld',
6 | environment,
7 | rootURL: '/',
8 | locationType: 'history',
9 | EmberENV: {
10 | FEATURES: {
11 | // Here you can enable experimental features on an ember canary build
12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
13 | EMBER_NATIVE_DECORATOR_SUPPORT: true,
14 | EMBER_METAL_TRACKED_PROPERTIES: true,
15 | EMBER_GLIMMER_ANGLE_BRACKET_NESTED_LOOKUP: true,
16 | EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS: true,
17 | },
18 | EXTEND_PROTOTYPES: {
19 | // Prevent Ember Data from overriding Date.parse.
20 | Date: false,
21 | },
22 | },
23 |
24 | APP: {
25 | apiHost: 'https://api.realworld.io/api',
26 | },
27 |
28 | 'ember-cli-mirage': {
29 | enabled: false,
30 | },
31 | };
32 |
33 | if (environment === 'development') {
34 | // ENV.APP.LOG_RESOLVER = true;
35 | // ENV.APP.LOG_ACTIVE_GENERATION = true;
36 | // ENV.APP.LOG_TRANSITIONS = true;
37 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
38 | // ENV.APP.LOG_VIEW_LOOKUPS = true;
39 | }
40 |
41 | if (environment === 'test') {
42 | // Testem prefers this...
43 | ENV.locationType = 'none';
44 |
45 | // keep test console output quieter
46 | ENV.APP.LOG_ACTIVE_GENERATION = false;
47 | ENV.APP.LOG_VIEW_LOOKUPS = false;
48 |
49 | ENV.APP.rootElement = '#ember-testing';
50 | ENV.APP.autoboot = false;
51 | ENV.APP.apiHost = '';
52 | }
53 |
54 | // if (environment === 'production') {
55 | // }
56 |
57 | return ENV;
58 | };
59 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/config/targets.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const browsers = ['last 1 Chrome versions', 'last 1 Firefox versions', 'last 1 Safari versions'];
4 |
5 | const isCI = Boolean(process.env.CI);
6 | const isProduction = process.env.EMBER_ENV === 'production';
7 |
8 | if (isCI || isProduction) {
9 | browsers.push('ie 11');
10 | }
11 |
12 | module.exports = {
13 | browsers,
14 | };
15 |
--------------------------------------------------------------------------------
/ember-cli-build.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const EmberApp = require('ember-cli/lib/broccoli/ember-app');
4 |
5 | module.exports = function (defaults) {
6 | let app = new EmberApp(defaults, {
7 | // Add options here
8 | });
9 |
10 | // Use `app.import` to add additional libraries to the generated
11 | // output files.
12 | //
13 | // If you need to use different assets in different
14 | // environments, specify an object as the first parameter. That
15 | // object's keys should be the environment name and the values
16 | // should be the asset to use in that environment.
17 | //
18 | // If the library that you are including contains AMD or ES6
19 | // modules that you would like to import into your application
20 | // please specify an object with the list of modules as keys
21 | // along with the exports of each module as its value.
22 |
23 | return app.toTree();
24 | };
25 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {"compilerOptions":{"target":"es6","experimentalDecorators":true},"exclude":["node_modules","bower_components","tmp","vendor",".git","dist"]}
--------------------------------------------------------------------------------
/logo-ember.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/firstninja111/Simple-Ember-JS-DEMO/bb9f55e3a141c4723d09abc264281c8f97d41d87/logo-ember.png
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/firstninja111/Simple-Ember-JS-DEMO/bb9f55e3a141c4723d09abc264281c8f97d41d87/logo.png
--------------------------------------------------------------------------------
/mirage/config.js:
--------------------------------------------------------------------------------
1 | import { Response } from 'ember-cli-mirage';
2 | import { isBlank, isPresent } from '@ember/utils';
3 |
4 | export const validateUserUsername = (username = '') => {
5 | const errors = [];
6 |
7 | username = username.trim();
8 |
9 | if (isBlank(username)) {
10 | errors.push("can't be blank");
11 | }
12 |
13 | if (username.length < 0) {
14 | errors.push('is too short (minimum is 1 character)');
15 | }
16 |
17 | if (username.length > 20) {
18 | errors.push('is too long (maximum is 20 characters)');
19 | }
20 |
21 | return errors;
22 | };
23 |
24 | export const validateUserEmail = (email = '') => {
25 | const errors = [];
26 |
27 | email = email.trim();
28 |
29 | if (isBlank(email)) {
30 | errors.push("can't be blank");
31 | }
32 |
33 | return errors;
34 | };
35 |
36 | export const validateArticleTitle = (title = '') => {
37 | const errors = [];
38 |
39 | title = title.trim();
40 |
41 | if (isBlank(title)) {
42 | errors.push("can't be blank");
43 | }
44 |
45 | if (title.length < 0) {
46 | errors.push('is too short (minimum is 1 character)');
47 | }
48 |
49 | if (title.length > 200) {
50 | errors.push('is too long (maximum is 200 characters)');
51 | }
52 |
53 | return errors;
54 | };
55 |
56 | export const validateArticleBody = (body = '') => {
57 | const errors = [];
58 |
59 | body = body.trim();
60 |
61 | if (isBlank(body)) {
62 | errors.push("can't be blank");
63 | }
64 |
65 | return errors;
66 | };
67 |
68 | export const validateArticleDescription = (description = '') => {
69 | const errors = [];
70 |
71 | description = description.trim();
72 |
73 | if (isBlank(description)) {
74 | errors.push("can't be blank");
75 | }
76 |
77 | if (description.length < 0) {
78 | errors.push('is too short (minimum is 1 character)');
79 | }
80 |
81 | if (description.length > 500) {
82 | errors.push('is too long (maximum is 500 characters)');
83 | }
84 |
85 | return errors;
86 | };
87 |
88 | export default function () {
89 | this.namespace = ''; // make this `/api`, for example, if your API is namespaced
90 | this.timing = 400; // delay for each request, automatically set to 0 during testing
91 |
92 | /**
93 | * Authentication
94 | */
95 | this.post('/users/login', (schema, request) => {
96 | const attrs = JSON.parse(request.requestBody).user;
97 | return schema.users.findBy({ email: attrs.email });
98 | });
99 |
100 | /**
101 | * User registration
102 | */
103 | this.post('/users', (schema, request) => {
104 | const attrs = JSON.parse(request.requestBody).user;
105 | return schema.users.findBy({ email: attrs.email });
106 | });
107 |
108 | /**
109 | * Get current user
110 | */
111 | this.get('/user', (schema, request) => {
112 | const { authorization } = request.requestHeaders;
113 | if (authorization) {
114 | const [authType, token] = request.requestHeaders.authorization.split(' ');
115 |
116 | if (authType === 'Token' && token) {
117 | const user = schema.users.findBy({ token });
118 |
119 | if (user) {
120 | return user;
121 | }
122 | }
123 | }
124 |
125 | return new Response(401, {}, {});
126 | });
127 |
128 | /**
129 | * Update current user
130 | */
131 | this.put('/user', (schema, request) => {
132 | const body = JSON.parse(request.requestBody);
133 | const { user: userData } = body;
134 | const { email, username } = userData;
135 | const user = schema.users.find(username);
136 |
137 | const errors = {
138 | username: validateUserUsername(username),
139 | email: validateUserEmail(email),
140 | };
141 |
142 | const filteredErrors = Object.entries(errors).reduce((acc, [key, arr]) => {
143 | if (arr.length) {
144 | acc[key] = arr;
145 | }
146 | return acc;
147 | }, {});
148 |
149 | if (Object.keys(filteredErrors).length) {
150 | return new Response(422, {}, { errors: filteredErrors });
151 | }
152 |
153 | /**
154 | * Look up profile by the user's old username in order to update it.
155 | */
156 | const profile = schema.profiles.findBy({ username: user.username });
157 | profile.update(userData);
158 |
159 | return user.update(userData);
160 | });
161 |
162 | this.get('/articles', (schema, request) => {
163 | const params = request.queryParams;
164 | if (params.author) {
165 | const { author } = params;
166 |
167 | return schema.articles.all().filter((article) => article.author.username === author);
168 | } else if (params.favorited) {
169 | /**
170 | * TODO: Currently there is no way to identify articles favorited by different profiles.
171 | * This could cause some confusion and difficulty in testing.
172 | *
173 | * Consider creating a model that contains an array of favorite articles per user.
174 | */
175 | const { favorited } = params;
176 |
177 | return schema.articles
178 | .all()
179 | .filter((article) => article.favorited && article.author.id !== favorited);
180 | } else {
181 | const allArticles = schema.articles.all(),
182 | limit = parseInt(params.limit),
183 | page = parseInt(params.offset) / limit,
184 | start = page * limit,
185 | end = start + limit,
186 | newArticles = allArticles.models.slice(start, end),
187 | newSchema = {};
188 | newSchema.articles = newArticles;
189 | newSchema.articlesCount = allArticles.length;
190 |
191 | return newSchema;
192 | }
193 | });
194 |
195 | /**
196 | * Get feed articles
197 | */
198 | // this.get('/articles/feed', (schema, request) => {});
199 |
200 | /**
201 | * Create article
202 | */
203 | this.post('/articles', (schema, request) => {
204 | const {
205 | article: { title, body, description, tagList },
206 | } = JSON.parse(request.requestBody);
207 | const errors = {
208 | title: validateArticleTitle(title),
209 | description: validateArticleDescription(description),
210 | body: validateArticleBody(body),
211 | };
212 |
213 | const filteredErrors = Object.entries(errors).reduce((acc, [key, arr]) => {
214 | if (arr.length) {
215 | acc[key] = arr;
216 | }
217 | return acc;
218 | }, {});
219 |
220 | if (Object.keys(filteredErrors).length) {
221 | return new Response(422, {}, { errors: filteredErrors });
222 | }
223 |
224 | return this.create('article', {
225 | title,
226 | body,
227 | description,
228 | tagList: tagList.filter(isPresent).invoke('trim'),
229 | });
230 | });
231 |
232 | /**
233 | * Get an article by ID
234 | */
235 | this.get('/articles/:slug', (schema, request) => {
236 | const slug = request.params.slug;
237 | const article = schema.articles.findBy({
238 | slug,
239 | });
240 | return article;
241 | });
242 |
243 | /**
244 | * Update an article by ID
245 | */
246 | this.put('/articles/:slug', (schema, request) => {
247 | const slug = request.params.slug;
248 | const {
249 | article: { title, body, description, tagList },
250 | } = JSON.parse(request.requestBody);
251 | const errors = {
252 | title: validateArticleTitle(title),
253 | description: validateArticleDescription(description),
254 | body: validateArticleBody(body),
255 | };
256 |
257 | const filteredErrors = Object.entries(errors).reduce((acc, [key, arr]) => {
258 | if (arr.length) {
259 | acc[key] = arr;
260 | }
261 | return acc;
262 | }, {});
263 |
264 | if (Object.keys(filteredErrors).length) {
265 | return new Response(422, {}, { errors: filteredErrors });
266 | }
267 |
268 | return schema.articles
269 | .findBy({
270 | slug,
271 | })
272 | .update({
273 | title,
274 | body,
275 | description,
276 | tagList: tagList.filter(isPresent).invoke('trim'),
277 | });
278 | });
279 |
280 | /**
281 | * Delete an article by ID
282 | */
283 | this.delete('/articles/:slug', (schema, request) => {
284 | const slug = request.params.slug;
285 | const article = schema.articles.findBy({
286 | slug,
287 | });
288 |
289 | return article.destroy();
290 | });
291 |
292 | /**
293 | * Favorite an article by ID
294 | */
295 | this.post('/articles/:slug/favorite', (schema, request) => {
296 | const slug = request.params.slug;
297 | const article = schema.articles.findBy({
298 | slug,
299 | });
300 |
301 | return article.update({
302 | favorited: true,
303 | favoritesCount: article.favoritesCount + 1,
304 | });
305 | });
306 |
307 | /**
308 | * Unfavorite an article by ID
309 | */
310 | this.delete('/articles/:slug/favorite', (schema, request) => {
311 | const slug = request.params.slug;
312 | const article = schema.articles.findBy({
313 | slug,
314 | });
315 |
316 | return article.update({
317 | favorited: false,
318 | favoritesCount: article.favoritesCount - 1,
319 | });
320 | });
321 |
322 | /**
323 | * Get an article's comments
324 | */
325 | this.get('/articles/:slug/comments', (schema, request) => {
326 | const slug = request.params.slug;
327 | const article = schema.articles.findBy({ slug });
328 | const comments = schema.comments.where({
329 | articleId: article.id,
330 | });
331 | return comments;
332 | });
333 |
334 | /**
335 | * Create an article's comment
336 | */
337 | this.post('/articles/:slug/comments', (schema, request) => {
338 | const slug = request.params.slug;
339 | const message = JSON.parse(request.requestBody).message;
340 | const article = schema.articles.findBy({ slug });
341 | const user = schema.users.first();
342 | const author = schema.profiles.findBy({ username: user.username });
343 |
344 | return schema.comments.create({
345 | message,
346 | article,
347 | createdAt: new Date().toISOString(),
348 | updatedAt: new Date().toISOString(),
349 | author,
350 | });
351 | });
352 |
353 | /**
354 | * Delete an article's comment
355 | */
356 | this.delete('/articles/:slug/comments/:id', (schema, request) => {
357 | const id = request.params.id;
358 | const comment = schema.comments.find(id);
359 |
360 | return comment.destroy();
361 | });
362 |
363 | this.get('/tags', () => {
364 | return {
365 | tags: ['emberjs', 'tomster', 'wycats', 'tomdale', 'ember-cli', 'training', 'dragons'],
366 | };
367 | });
368 |
369 | this.get('/profiles/:username', (schema, request) => {
370 | const username = request.params.username;
371 |
372 | return schema.profiles.findBy({ username });
373 | });
374 |
375 | this.post('/profiles/:username/follow', (schema, request) => {
376 | const username = request.params.username;
377 | const profile = schema.profiles.findBy({ username });
378 |
379 | return profile.update({
380 | following: true,
381 | });
382 | });
383 |
384 | this.delete('/profiles/:username/follow', (schema, request) => {
385 | const username = request.params.username;
386 | const profile = schema.profiles.findBy({ username });
387 |
388 | return profile.update({
389 | following: false,
390 | });
391 | });
392 | }
393 |
--------------------------------------------------------------------------------
/mirage/factories/article.js:
--------------------------------------------------------------------------------
1 | import { Factory, association } from 'ember-cli-mirage';
2 | import faker from 'faker';
3 |
4 | const tags = ['emberjs', 'tomster', 'wycats', 'tomdale', 'ember-cli', 'training', 'dragons'];
5 |
6 | export default Factory.extend({
7 | author: association(),
8 |
9 | title() {
10 | return faker.lorem.words();
11 | },
12 |
13 | description() {
14 | return faker.lorem.paragraphs();
15 | },
16 |
17 | body() {
18 | return faker.lorem.paragraphs();
19 | },
20 |
21 | tagList() {
22 | if (faker.random.boolean()) {
23 | return [faker.random.arrayElement(tags), faker.random.arrayElement(tags)];
24 | } else {
25 | return [];
26 | }
27 | },
28 |
29 | createdAt() {
30 | return faker.date.recent();
31 | },
32 |
33 | updatedAt() {
34 | return faker.date.recent();
35 | },
36 |
37 | favorited() {
38 | return faker.random.boolean();
39 | },
40 |
41 | favoritesCount() {
42 | return faker.random.number(100);
43 | },
44 |
45 | slug() {
46 | return faker.helpers.slugify(this.title);
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/mirage/factories/author.js:
--------------------------------------------------------------------------------
1 | import { Factory } from 'ember-cli-mirage';
2 | import faker from 'faker';
3 |
4 | export default Factory.extend({
5 | username() {
6 | return faker.internet.userName();
7 | },
8 |
9 | bio() {
10 | return faker.lorem.sentence();
11 | },
12 |
13 | image() {
14 | return faker.internet.avatar();
15 | },
16 |
17 | following() {
18 | return faker.random.boolean();
19 | },
20 | });
21 |
--------------------------------------------------------------------------------
/mirage/factories/comment.js:
--------------------------------------------------------------------------------
1 | import { Factory, association } from 'ember-cli-mirage';
2 | import faker from 'faker';
3 |
4 | export default Factory.extend({
5 | author: association(),
6 | article: association(),
7 |
8 | createdAt() {
9 | return faker.date.recent();
10 | },
11 |
12 | updatedAt() {
13 | return faker.date.recent();
14 | },
15 |
16 | body() {
17 | return faker.lorem.paragraphs();
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/mirage/factories/profile.js:
--------------------------------------------------------------------------------
1 | import { Factory } from 'ember-cli-mirage';
2 | import faker from 'faker';
3 |
4 | export default Factory.extend({
5 | token: 'auth-token',
6 |
7 | image: null,
8 |
9 | email() {
10 | return faker.internet.email();
11 | },
12 |
13 | username() {
14 | return faker.internet.userName();
15 | },
16 |
17 | bio() {
18 | return faker.lorem.paragraph();
19 | },
20 |
21 | following() {
22 | return faker.random.boolean();
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/mirage/factories/user.js:
--------------------------------------------------------------------------------
1 | import { Factory } from 'ember-cli-mirage';
2 | import faker from 'faker';
3 |
4 | export default Factory.extend({
5 | token: 'auth-token',
6 |
7 | image: null,
8 |
9 | email() {
10 | return faker.internet.email();
11 | },
12 |
13 | username() {
14 | return faker.internet.userName();
15 | },
16 |
17 | bio() {
18 | return faker.lorem.paragraph();
19 | },
20 |
21 | afterCreate(user, server) {
22 | const { image, email, username, bio } = user;
23 | server.create('profile', {
24 | image,
25 | email,
26 | username,
27 | bio,
28 | });
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/mirage/models/article.js:
--------------------------------------------------------------------------------
1 | import { Model, belongsTo } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({
4 | author: belongsTo('profile'),
5 | });
6 |
--------------------------------------------------------------------------------
/mirage/models/author.js:
--------------------------------------------------------------------------------
1 | import { Model } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({});
4 |
--------------------------------------------------------------------------------
/mirage/models/comment.js:
--------------------------------------------------------------------------------
1 | import { Model, belongsTo } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({
4 | author: belongsTo('profile'),
5 | article: belongsTo('article'),
6 | });
7 |
--------------------------------------------------------------------------------
/mirage/models/profile.js:
--------------------------------------------------------------------------------
1 | import { Model } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({});
4 |
--------------------------------------------------------------------------------
/mirage/models/tag.js:
--------------------------------------------------------------------------------
1 | import { Model } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({});
4 |
--------------------------------------------------------------------------------
/mirage/models/user.js:
--------------------------------------------------------------------------------
1 | import { Model } from 'ember-cli-mirage';
2 |
3 | export default Model.extend({});
4 |
--------------------------------------------------------------------------------
/mirage/scenarios/default.js:
--------------------------------------------------------------------------------
1 | export default function (server) {
2 | /*
3 | Seed your development database using your factories.
4 | This data will not be loaded in your tests.
5 |
6 | Make sure to define a factory for each model you want to create.
7 | */
8 |
9 | server.create('user', { email: 'email@example.com', password: 'password' });
10 | server.createList('article', 20);
11 | }
12 |
--------------------------------------------------------------------------------
/mirage/serializers/application.js:
--------------------------------------------------------------------------------
1 | import { RestSerializer } from 'ember-cli-mirage';
2 |
3 | export default RestSerializer.extend({
4 | embed: true,
5 | });
6 |
--------------------------------------------------------------------------------
/mirage/serializers/article.js:
--------------------------------------------------------------------------------
1 | import BaseSerializer from './application';
2 |
3 | export default BaseSerializer.extend({
4 | include: Object.freeze(['author']),
5 | });
6 |
--------------------------------------------------------------------------------
/mirage/serializers/comment.js:
--------------------------------------------------------------------------------
1 | import BaseSerializer from './application';
2 |
3 | export default BaseSerializer.extend({
4 | include: Object.freeze(['author']),
5 | });
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ember-realworld",
3 | "version": "0.1.0",
4 | "private": true,
5 | "description": "This codebase was created to demonstrate a fully fledged fullstack application built with **Ember** including CRUD operations, authentication, routing, pagination, and more.",
6 | "repository": "https://github.com/gothinkster/ember-realworld",
7 | "license": "MIT",
8 | "author": "Alon Bukai",
9 | "directories": {
10 | "doc": "doc",
11 | "test": "tests"
12 | },
13 | "contributors": [
14 | "Alon Bukai",
15 | "Alex LaFroscia",
16 | "Jonathan Goldman",
17 | "Laura Kajpust",
18 | "Garrick Cheung",
19 | "Pat O' Callaghan",
20 | "Chris Manson",
21 | "Ryan Mark"
22 | ],
23 | "scripts": {
24 | "build": "ember build --environment=production",
25 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel 'lint:!(fix)'",
26 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix",
27 | "lint:hbs": "ember-template-lint .",
28 | "lint:hbs:fix": "ember-template-lint . --fix",
29 | "lint:js": "eslint . --cache",
30 | "lint:js:fix": "eslint . --fix",
31 | "start": "ember serve",
32 | "start:dev": "ember server --proxy https://conduit.productionready.io",
33 | "test": "npm-run-all lint test:*",
34 | "test:ember": "ember test"
35 | },
36 | "devDependencies": {
37 | "@ember/optional-features": "^2.0.0",
38 | "@ember/render-modifiers": "^1.0.2",
39 | "@ember/test-helpers": "^2.1.4",
40 | "@glimmer/component": "^1.0.3",
41 | "@glimmer/tracking": "^1.0.3",
42 | "@types/ember": "^3.1.0",
43 | "@types/ember-data": "^3.1.6",
44 | "@types/ember-qunit": "^3.4.6",
45 | "@types/ember-testing-helpers": "^0.0.3",
46 | "@types/qunit": "^2.5.4",
47 | "babel-eslint": "^10.1.0",
48 | "broccoli-asset-rev": "^3.0.0",
49 | "ember-auto-import": "^1.10.1",
50 | "ember-cli": "~3.24.0",
51 | "ember-cli-app-version": "^4.0.0",
52 | "ember-cli-babel": "^7.23.0",
53 | "ember-cli-dependency-checker": "^3.2.0",
54 | "ember-cli-deprecation-workflow": "^1.0.1",
55 | "ember-cli-htmlbars": "^5.3.1",
56 | "ember-cli-inject-live-reload": "^2.0.2",
57 | "ember-cli-mirage": "^1.1.6",
58 | "ember-cli-sri": "^2.1.1",
59 | "ember-cli-terser": "^4.0.1",
60 | "ember-concurrency": "^1.3.0",
61 | "ember-concurrency-decorators": "^2.0.3",
62 | "ember-data": "~3.24.0",
63 | "ember-export-application-global": "^2.0.1",
64 | "ember-fetch": "^8.0.2",
65 | "ember-load-initializers": "^2.1.2",
66 | "ember-maybe-import-regenerator": "^0.1.6",
67 | "ember-page-title": "^6.0.3",
68 | "ember-qunit": "^5.1.1",
69 | "ember-resolver": "^8.0.2",
70 | "ember-source": "~3.24.0",
71 | "ember-template-lint": "^2.15.0",
72 | "ember-test-selectors": "^4.0.0",
73 | "ember-truth-helpers": "^2.1.0",
74 | "eslint": "^7.17.0",
75 | "eslint-config-prettier": "^7.1.0",
76 | "eslint-plugin-ember": "^10.1.1",
77 | "eslint-plugin-node": "^11.1.0",
78 | "eslint-plugin-prettier": "^3.3.1",
79 | "faker": "^4.1.0",
80 | "loader.js": "^4.7.0",
81 | "marked": "^0.6.1",
82 | "npm-run-all": "^4.1.5",
83 | "prettier": "^2.2.1",
84 | "pretty-quick": "^1.10.0",
85 | "qunit": "^2.13.0",
86 | "qunit-dom": "^1.6.0",
87 | "sinon": "^8.1.1"
88 | },
89 | "engines": {
90 | "node": "10.* || >= 12"
91 | },
92 | "ember": {
93 | "edition": "octane"
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/postman/Conduit.json.postman_collection.json:
--------------------------------------------------------------------------------
1 | {
2 | "variables": [],
3 | "info": {
4 | "name": "Conduit",
5 | "_postman_id": "3b596692-227e-d335-1017-30ce81cb8bc5",
6 | "description": "Collection for testing the Conduit API",
7 | "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json"
8 | },
9 | "item": [{
10 | "name": "Articles",
11 | "description": "",
12 | "item": [{
13 | "name": "Feed",
14 | "event": [{
15 | "listen": "test",
16 | "script": {
17 | "type": "text/javascript",
18 | "exec": [
19 | "var is200Response = responseCode.code === 200;",
20 | "",
21 | "tests['Response code is 200 OK'] = is200Response;",
22 | "",
23 | "if(is200Response){",
24 | " var responseJSON = JSON.parse(responseBody);",
25 | "",
26 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
27 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
28 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
29 | "",
30 | " if(responseJSON.articles.length){",
31 | " var article = responseJSON.articles[0];",
32 | "",
33 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
34 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
35 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
36 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
37 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
38 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
39 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
40 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
41 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
42 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
43 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
44 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
45 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
46 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
47 | " } else {",
48 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
49 | " }",
50 | "}",
51 | ""
52 | ]
53 | }
54 | }],
55 | "request": {
56 | "url": "{{apiUrl}}/articles/feed",
57 | "method": "GET",
58 | "header": [{
59 | "key": "Content-Type",
60 | "value": "application/json",
61 | "description": ""
62 | },
63 | {
64 | "key": "Authorization",
65 | "value": "Token {{token}}",
66 | "description": ""
67 | }
68 | ],
69 | "body": {},
70 | "description": ""
71 | },
72 | "response": []
73 | },
74 | {
75 | "name": "All Articles",
76 | "event": [{
77 | "listen": "test",
78 | "script": {
79 | "type": "text/javascript",
80 | "exec": [
81 | "var is200Response = responseCode.code === 200;",
82 | "",
83 | "tests['Response code is 200 OK'] = is200Response;",
84 | "",
85 | "if(is200Response){",
86 | " var responseJSON = JSON.parse(responseBody);",
87 | "",
88 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
89 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
90 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
91 | "",
92 | " if(responseJSON.articles.length){",
93 | " var article = responseJSON.articles[0];",
94 | "",
95 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
96 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
97 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
98 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
99 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
100 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
101 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
102 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
103 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
104 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
105 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
106 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
107 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
108 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
109 | " } else {",
110 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
111 | " }",
112 | "}",
113 | ""
114 | ]
115 | }
116 | }],
117 | "request": {
118 | "url": "{{apiUrl}}/articles",
119 | "method": "GET",
120 | "header": [{
121 | "key": "Content-Type",
122 | "value": "application/json",
123 | "description": ""
124 | }],
125 | "body": {},
126 | "description": ""
127 | },
128 | "response": []
129 | },
130 | {
131 | "name": "Articles by Author",
132 | "event": [{
133 | "listen": "test",
134 | "script": {
135 | "type": "text/javascript",
136 | "exec": [
137 | "var is200Response = responseCode.code === 200;",
138 | "",
139 | "tests['Response code is 200 OK'] = is200Response;",
140 | "",
141 | "if(is200Response){",
142 | " var responseJSON = JSON.parse(responseBody);",
143 | "",
144 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
145 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
146 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
147 | "",
148 | " if(responseJSON.articles.length){",
149 | " var article = responseJSON.articles[0];",
150 | "",
151 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
152 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
153 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
154 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
155 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
156 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
157 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
158 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
159 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
160 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
161 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
162 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
163 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
164 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
165 | " } else {",
166 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
167 | " }",
168 | "}",
169 | ""
170 | ]
171 | }
172 | }],
173 | "request": {
174 | "url": "{{apiUrl}}/articles?author=jack",
175 | "method": "GET",
176 | "header": [{
177 | "key": "Content-Type",
178 | "value": "application/json",
179 | "description": ""
180 | }],
181 | "body": {},
182 | "description": ""
183 | },
184 | "response": []
185 | },
186 | {
187 | "name": "Articles Favorited by Username",
188 | "event": [{
189 | "listen": "test",
190 | "script": {
191 | "type": "text/javascript",
192 | "exec": [
193 | "var is200Response = responseCode.code === 200;",
194 | "",
195 | "tests['Response code is 200 OK'] = is200Response;",
196 | "",
197 | "if(is200Response){",
198 | " var responseJSON = JSON.parse(responseBody);",
199 | " ",
200 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
201 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
202 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
203 | "",
204 | " if(responseJSON.articles.length){",
205 | " var article = responseJSON.articles[0];",
206 | "",
207 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
208 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
209 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
210 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
211 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
212 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
213 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
214 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
215 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
216 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
217 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
218 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
219 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
220 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
221 | " } else {",
222 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
223 | " }",
224 | "}",
225 | ""
226 | ]
227 | }
228 | }],
229 | "request": {
230 | "url": "{{apiUrl}}/articles?favorited=jack",
231 | "method": "GET",
232 | "header": [{
233 | "key": "Content-Type",
234 | "value": "application/json",
235 | "description": ""
236 | }],
237 | "body": {},
238 | "description": ""
239 | },
240 | "response": []
241 | },
242 | {
243 | "name": "Articles by Tag",
244 | "event": [{
245 | "listen": "test",
246 | "script": {
247 | "type": "text/javascript",
248 | "exec": [
249 | "var is200Response = responseCode.code === 200;",
250 | "",
251 | "tests['Response code is 200 OK'] = is200Response;",
252 | "",
253 | "if(is200Response){",
254 | " var responseJSON = JSON.parse(responseBody);",
255 | "",
256 | " tests['Response contains \"articles\" property'] = responseJSON.hasOwnProperty('articles');",
257 | " tests['Response contains \"articlesCount\" property'] = responseJSON.hasOwnProperty('articlesCount');",
258 | " tests['articlesCount is an integer'] = Number.isInteger(responseJSON.articlesCount);",
259 | "",
260 | " if(responseJSON.articles.length){",
261 | " var article = responseJSON.articles[0];",
262 | "",
263 | " tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
264 | " tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
265 | " tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
266 | " tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
267 | " tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
268 | " tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
269 | " tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
270 | " tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
271 | " tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
272 | " tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
273 | " tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
274 | " tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
275 | " tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
276 | " tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
277 | " } else {",
278 | " tests['articlesCount is 0 when feed is empty'] = responseJSON.articlesCount === 0;",
279 | " }",
280 | "}",
281 | ""
282 | ]
283 | }
284 | }],
285 | "request": {
286 | "url": "{{apiUrl}}/articles?tag=dragons",
287 | "method": "GET",
288 | "header": [{
289 | "key": "Content-Type",
290 | "value": "application/json",
291 | "description": ""
292 | }],
293 | "body": {},
294 | "description": ""
295 | },
296 | "response": []
297 | },
298 | {
299 | "name": "Create Article",
300 | "event": [{
301 | "listen": "test",
302 | "script": {
303 | "type": "text/javascript",
304 | "exec": [
305 | "var responseJSON = JSON.parse(responseBody);",
306 | "",
307 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
308 | "",
309 | "var article = responseJSON.article || {};",
310 | "",
311 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
312 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
313 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
314 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
315 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
316 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
317 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
318 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
319 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
320 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
321 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
322 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
323 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
324 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
325 | ""
326 | ]
327 | }
328 | }],
329 | "request": {
330 | "url": "{{apiUrl}}/articles",
331 | "method": "POST",
332 | "header": [{
333 | "key": "Content-Type",
334 | "value": "application/json",
335 | "description": ""
336 | },
337 | {
338 | "key": "Authorization",
339 | "value": "Token {{token}}",
340 | "description": ""
341 | }
342 | ],
343 | "body": {
344 | "mode": "raw",
345 | "raw": "{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"dragons\",\"training\"]}}"
346 | },
347 | "description": ""
348 | },
349 | "response": []
350 | },
351 | {
352 | "name": "Single Article by slug",
353 | "event": [{
354 | "listen": "test",
355 | "script": {
356 | "type": "text/javascript",
357 | "exec": [
358 | "var responseJSON = JSON.parse(responseBody);",
359 | "",
360 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
361 | "",
362 | "var article = responseJSON.article || {};",
363 | "",
364 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
365 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
366 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
367 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
368 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
369 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
370 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
371 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
372 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
373 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
374 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
375 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
376 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
377 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
378 | ""
379 | ]
380 | }
381 | }],
382 | "request": {
383 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon",
384 | "method": "GET",
385 | "header": [{
386 | "key": "Content-Type",
387 | "value": "application/json",
388 | "description": ""
389 | }],
390 | "body": {},
391 | "description": ""
392 | },
393 | "response": []
394 | },
395 | {
396 | "name": "Delete Article",
397 | "request": {
398 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon",
399 | "method": "DELETE",
400 | "header": [{
401 | "key": "Content-Type",
402 | "value": "application/json",
403 | "description": ""
404 | },
405 | {
406 | "key": "Authorization",
407 | "value": "Token {{token}}",
408 | "description": ""
409 | }
410 | ],
411 | "body": {
412 | "mode": "raw",
413 | "raw": ""
414 | },
415 | "description": ""
416 | },
417 | "response": []
418 | },
419 | {
420 | "name": "Update Article",
421 | "event": [{
422 | "listen": "test",
423 | "script": {
424 | "type": "text/javascript",
425 | "exec": [
426 | "var responseJSON = JSON.parse(responseBody);",
427 | "",
428 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
429 | "",
430 | "var article = responseJSON.article || {};",
431 | "",
432 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
433 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
434 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
435 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
436 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
437 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
438 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
439 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
440 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
441 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
442 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
443 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
444 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
445 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
446 | ""
447 | ]
448 | }
449 | }],
450 | "request": {
451 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon",
452 | "method": "PUT",
453 | "header": [{
454 | "key": "Content-Type",
455 | "value": "application/json",
456 | "description": ""
457 | },
458 | {
459 | "key": "Authorization",
460 | "value": "Token {{token}}",
461 | "description": ""
462 | }
463 | ],
464 | "body": {
465 | "mode": "raw",
466 | "raw": "{\"article\":{\"body\":\"With two hands\"}}"
467 | },
468 | "description": ""
469 | },
470 | "response": []
471 | },
472 | {
473 | "name": "Favorite Article",
474 | "event": [{
475 | "listen": "test",
476 | "script": {
477 | "type": "text/javascript",
478 | "exec": [
479 | "var responseJSON = JSON.parse(responseBody);",
480 | "",
481 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
482 | "",
483 | "var article = responseJSON.article || {};",
484 | "",
485 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
486 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
487 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
488 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
489 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
490 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
491 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
492 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
493 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
494 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
495 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
496 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
497 | "tests[\"Article's 'favorited' property is true\"] = article.favorited === true;",
498 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
499 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
500 | "tests[\"Article's 'favoritesCount' property is greater than 0\"] = article.favoritesCount > 0;",
501 | ""
502 | ]
503 | }
504 | }],
505 | "request": {
506 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon/favorite",
507 | "method": "POST",
508 | "header": [{
509 | "key": "Content-Type",
510 | "value": "application/json",
511 | "description": ""
512 | },
513 | {
514 | "key": "Authorization",
515 | "value": "Token {{token}}",
516 | "description": ""
517 | }
518 | ],
519 | "body": {
520 | "mode": "raw",
521 | "raw": ""
522 | },
523 | "description": ""
524 | },
525 | "response": []
526 | },
527 | {
528 | "name": "Unfavorite Article",
529 | "event": [{
530 | "listen": "test",
531 | "script": {
532 | "type": "text/javascript",
533 | "exec": [
534 | "var responseJSON = JSON.parse(responseBody);",
535 | "",
536 | "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');",
537 | "",
538 | "var article = responseJSON.article || {};",
539 | "",
540 | "tests['Article has \"title\" property'] = article.hasOwnProperty('title');",
541 | "tests['Article has \"slug\" property'] = article.hasOwnProperty('slug');",
542 | "tests['Article has \"body\" property'] = article.hasOwnProperty('body');",
543 | "tests['Article has \"createdAt\" property'] = article.hasOwnProperty('createdAt');",
544 | "tests['Article\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(article.createdAt).toISOString() === article.createdAt;",
545 | "tests['Article has \"updatedAt\" property'] = article.hasOwnProperty('updatedAt');",
546 | "tests['Article\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(article.updatedAt).toISOString() === article.updatedAt;",
547 | "tests['Article has \"description\" property'] = article.hasOwnProperty('description');",
548 | "tests['Article has \"tagList\" property'] = article.hasOwnProperty('tagList');",
549 | "tests['Article\\'s \"tagList\" property is an Array'] = Array.isArray(article.tagList);",
550 | "tests['Article has \"author\" property'] = article.hasOwnProperty('author');",
551 | "tests['Article has \"favorited\" property'] = article.hasOwnProperty('favorited');",
552 | "tests['Article has \"favoritesCount\" property'] = article.hasOwnProperty('favoritesCount');",
553 | "tests['favoritesCount is an integer'] = Number.isInteger(article.favoritesCount);",
554 | "tests[\"Article's \\\"favorited\\\" property is true\"] = article.favorited === false;",
555 | ""
556 | ]
557 | }
558 | }],
559 | "request": {
560 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon/favorite",
561 | "method": "DELETE",
562 | "header": [{
563 | "key": "Content-Type",
564 | "value": "application/json",
565 | "description": ""
566 | },
567 | {
568 | "key": "Authorization",
569 | "value": "Token {{token}}",
570 | "description": ""
571 | }
572 | ],
573 | "body": {
574 | "mode": "raw",
575 | "raw": ""
576 | },
577 | "description": ""
578 | },
579 | "response": []
580 | }
581 | ]
582 | },
583 | {
584 | "name": "Auth",
585 | "description": "",
586 | "item": [{
587 | "name": "Login",
588 | "event": [{
589 | "listen": "test",
590 | "script": {
591 | "type": "text/javascript",
592 | "exec": [
593 | "var responseJSON = JSON.parse(responseBody);",
594 | "",
595 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
596 | "",
597 | "var user = responseJSON.user || {};",
598 | "",
599 | "tests['User has \"id\" property'] = user.hasOwnProperty('id');",
600 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
601 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
602 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
603 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
604 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
605 | ""
606 | ]
607 | }
608 | }],
609 | "request": {
610 | "url": "{{apiUrl}}/users/login",
611 | "method": "POST",
612 | "header": [{
613 | "key": "Content-Type",
614 | "value": "application/json",
615 | "description": ""
616 | }],
617 | "body": {
618 | "mode": "raw",
619 | "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\"}}"
620 | },
621 | "description": ""
622 | },
623 | "response": []
624 | },
625 | {
626 | "name": "Login and Remember Token",
627 | "event": [{
628 | "listen": "test",
629 | "script": {
630 | "type": "text/javascript",
631 | "exec": [
632 | "var responseJSON = JSON.parse(responseBody);",
633 | "",
634 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
635 | "",
636 | "var user = responseJSON.user || {};",
637 | "",
638 | "tests['User has \"id\" property'] = user.hasOwnProperty('id');",
639 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
640 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
641 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
642 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
643 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
644 | "",
645 | "if(tests['User has \"token\" property']){",
646 | " postman.setEnvironmentVariable('token', user.token);",
647 | "}",
648 | "",
649 | "tests['Environment variable \"token\" has been set'] = environment.token === user.token;",
650 | ""
651 | ]
652 | }
653 | }],
654 | "request": {
655 | "url": "{{apiUrl}}/users/login",
656 | "method": "POST",
657 | "header": [{
658 | "key": "Content-Type",
659 | "value": "application/json",
660 | "description": ""
661 | }],
662 | "body": {
663 | "mode": "raw",
664 | "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\"}}"
665 | },
666 | "description": ""
667 | },
668 | "response": []
669 | },
670 | {
671 | "name": "Register",
672 | "event": [{
673 | "listen": "test",
674 | "script": {
675 | "type": "text/javascript",
676 | "exec": [
677 | "var responseJSON = JSON.parse(responseBody);",
678 | "",
679 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
680 | "",
681 | "var user = responseJSON.user || {};",
682 | "",
683 | "tests['User has \"id\" property'] = user.hasOwnProperty('id');",
684 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
685 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
686 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
687 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
688 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
689 | ""
690 | ]
691 | }
692 | }],
693 | "request": {
694 | "url": "{{apiUrl}}/users",
695 | "method": "POST",
696 | "header": [{
697 | "key": "Content-Type",
698 | "value": "application/json",
699 | "description": ""
700 | }],
701 | "body": {
702 | "mode": "raw",
703 | "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\", \"username\":\"johnjacob\"}}"
704 | },
705 | "description": ""
706 | },
707 | "response": []
708 | },
709 | {
710 | "name": "Current User",
711 | "event": [{
712 | "listen": "test",
713 | "script": {
714 | "type": "text/javascript",
715 | "exec": [
716 | "var responseJSON = JSON.parse(responseBody);",
717 | "",
718 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
719 | "",
720 | "var user = responseJSON.user || {};",
721 | "",
722 | "tests['User has \"id\" property'] = user.hasOwnProperty('id');",
723 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
724 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
725 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
726 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
727 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
728 | ""
729 | ]
730 | }
731 | }],
732 | "request": {
733 | "url": "{{apiUrl}}/user",
734 | "method": "GET",
735 | "header": [{
736 | "key": "Content-Type",
737 | "value": "application/json",
738 | "description": ""
739 | },
740 | {
741 | "key": "Authorization",
742 | "value": "Token {{token}}",
743 | "description": ""
744 | }
745 | ],
746 | "body": {},
747 | "description": ""
748 | },
749 | "response": []
750 | },
751 | {
752 | "name": "Update User",
753 | "event": [{
754 | "listen": "test",
755 | "script": {
756 | "type": "text/javascript",
757 | "exec": [
758 | "var responseJSON = JSON.parse(responseBody);",
759 | "",
760 | "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');",
761 | "",
762 | "var user = responseJSON.user || {};",
763 | "",
764 | "tests['User has \"id\" property'] = user.hasOwnProperty('id');",
765 | "tests['User has \"email\" property'] = user.hasOwnProperty('email');",
766 | "tests['User has \"username\" property'] = user.hasOwnProperty('username');",
767 | "tests['User has \"bio\" property'] = user.hasOwnProperty('bio');",
768 | "tests['User has \"image\" property'] = user.hasOwnProperty('image');",
769 | "tests['User has \"token\" property'] = user.hasOwnProperty('token');",
770 | ""
771 | ]
772 | }
773 | }],
774 | "request": {
775 | "url": "{{apiUrl}}/user",
776 | "method": "PUT",
777 | "header": [{
778 | "key": "Content-Type",
779 | "value": "application/json",
780 | "description": ""
781 | },
782 | {
783 | "key": "Authorization",
784 | "value": "Token {{token}}",
785 | "description": ""
786 | }
787 | ],
788 | "body": {
789 | "mode": "raw",
790 | "raw": "{\"user\":{\"email\":\"john@jacob.com\"}}"
791 | },
792 | "description": ""
793 | },
794 | "response": []
795 | }
796 | ]
797 | },
798 | {
799 | "name": "Comments",
800 | "description": "",
801 | "item": [{
802 | "name": "All Comments for Article",
803 | "event": [{
804 | "listen": "test",
805 | "script": {
806 | "type": "text/javascript",
807 | "exec": [
808 | "var is200Response = responseCode.code === 200",
809 | "",
810 | "tests['Response code is 200 OK'] = is200Response;",
811 | "",
812 | "if(is200Response){",
813 | " var responseJSON = JSON.parse(responseBody);",
814 | "",
815 | " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');",
816 | "",
817 | " if(responseJSON.comments.length){",
818 | " var comment = responseJSON.comments[0];",
819 | "",
820 | " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');",
821 | " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');",
822 | " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');",
823 | " tests['\"createdAt\" property is an ISO 8601 timestamp'] = new Date(comment.createdAt).toISOString() === comment.createdAt;",
824 | " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');",
825 | " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = new Date(comment.updatedAt).toISOString() === comment.updatedAt;",
826 | " tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');",
827 | " }",
828 | "}",
829 | ""
830 | ]
831 | }
832 | }],
833 | "request": {
834 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon/comments",
835 | "method": "GET",
836 | "header": [{
837 | "key": "Content-Type",
838 | "value": "application/json",
839 | "description": ""
840 | }],
841 | "body": {},
842 | "description": ""
843 | },
844 | "response": []
845 | },
846 | {
847 | "name": "Create Comment for Article",
848 | "event": [{
849 | "listen": "test",
850 | "script": {
851 | "type": "text/javascript",
852 | "exec": [
853 | "var responseJSON = JSON.parse(responseBody);",
854 | "",
855 | "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');",
856 | "",
857 | "var comment = responseJSON.comment || {};",
858 | "",
859 | "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');",
860 | "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');",
861 | "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');",
862 | "tests['\"createdAt\" property is an ISO 8601 timestamp'] = new Date(comment.createdAt).toISOString() === comment.createdAt;",
863 | "tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');",
864 | "tests['\"updatedAt\" property is an ISO 8601 timestamp'] = new Date(comment.updatedAt).toISOString() === comment.updatedAt;",
865 | "tests['Comment has \"author\" property'] = comment.hasOwnProperty('author');",
866 | ""
867 | ]
868 | }
869 | }],
870 | "request": {
871 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon/comments",
872 | "method": "POST",
873 | "header": [{
874 | "key": "Content-Type",
875 | "value": "application/json",
876 | "description": ""
877 | },
878 | {
879 | "key": "Authorization",
880 | "value": "Token {{token}}",
881 | "description": ""
882 | }
883 | ],
884 | "body": {
885 | "mode": "raw",
886 | "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}"
887 | },
888 | "description": ""
889 | },
890 | "response": []
891 | },
892 | {
893 | "name": "Delete Comment for Article",
894 | "request": {
895 | "url": "{{apiUrl}}/articles/how-to-train-your-dragon/comments/1",
896 | "method": "DELETE",
897 | "header": [{
898 | "key": "Content-Type",
899 | "value": "application/json",
900 | "description": ""
901 | },
902 | {
903 | "key": "Authorization",
904 | "value": "Token {{token}}",
905 | "description": ""
906 | }
907 | ],
908 | "body": {},
909 | "description": ""
910 | },
911 | "response": []
912 | }
913 | ]
914 | },
915 | {
916 | "name": "Profiles",
917 | "description": "",
918 | "item": [{
919 | "name": "Profile",
920 | "event": [{
921 | "listen": "test",
922 | "script": {
923 | "type": "text/javascript",
924 | "exec": [
925 | "var is200Response = responseCode.code === 200;",
926 | "",
927 | "tests['Response code is 200 OK'] = is200Response;",
928 | "",
929 | "if(is200Response){",
930 | " var responseJSON = JSON.parse(responseBody);",
931 | "",
932 | " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');",
933 | " ",
934 | " var profile = responseJSON.profile || {};",
935 | " ",
936 | " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');",
937 | " tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');",
938 | " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');",
939 | " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');",
940 | "}",
941 | ""
942 | ]
943 | }
944 | }],
945 | "request": {
946 | "url": "{{apiUrl}}/profiles/johnjacob",
947 | "method": "GET",
948 | "header": [{
949 | "key": "Content-Type",
950 | "value": "application/json",
951 | "description": ""
952 | }],
953 | "body": {},
954 | "description": ""
955 | },
956 | "response": []
957 | },
958 | {
959 | "name": "Follow Profile",
960 | "request": {
961 | "url": "{{apiUrl}}/profiles/jack/follow",
962 | "method": "POST",
963 | "header": [{
964 | "key": "Content-Type",
965 | "value": "application/json",
966 | "description": ""
967 | },
968 | {
969 | "key": "Authorization",
970 | "value": "Token {{token}}",
971 | "description": ""
972 | }
973 | ],
974 | "body": {},
975 | "description": ""
976 | },
977 | "response": []
978 | },
979 | {
980 | "name": "Unfollow Profile",
981 | "event": [{
982 | "listen": "test",
983 | "script": {
984 | "type": "text/javascript",
985 | "exec": [
986 | "var responseJSON = JSON.parse(responseBody);",
987 | "",
988 | "tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');",
989 | "",
990 | "var profile = responseJSON.profile || {};",
991 | "",
992 | "tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');",
993 | "tests['Profile has \"bio\" property'] = profile.hasOwnProperty('bio');",
994 | "tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');",
995 | "tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');",
996 | "tests['Profile\\'s \"following\" property is false'] = profile.following === false;"
997 | ]
998 | }
999 | }],
1000 | "request": {
1001 | "url": "{{apiUrl}}/profiles/jack/follow",
1002 | "method": "DELETE",
1003 | "header": [{
1004 | "key": "Content-Type",
1005 | "value": "application/json",
1006 | "description": ""
1007 | },
1008 | {
1009 | "key": "Authorization",
1010 | "value": "Token {{token}}",
1011 | "description": ""
1012 | }
1013 | ],
1014 | "body": {},
1015 | "description": ""
1016 | },
1017 | "response": []
1018 | }
1019 | ]
1020 | },
1021 | {
1022 | "name": "Tags",
1023 | "description": "",
1024 | "item": [{
1025 | "name": "All Tags",
1026 | "event": [{
1027 | "listen": "test",
1028 | "script": {
1029 | "type": "text/javascript",
1030 | "exec": [
1031 | "var is200Response = responseCode.code === 200;",
1032 | "",
1033 | "tests['Response code is 200 OK'] = is200Response;",
1034 | "",
1035 | "if(is200Response){",
1036 | " var responseJSON = JSON.parse(responseBody);",
1037 | " ",
1038 | " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');",
1039 | " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);",
1040 | "}",
1041 | ""
1042 | ]
1043 | }
1044 | }],
1045 | "request": {
1046 | "url": "{{apiUrl}}/tags",
1047 | "method": "GET",
1048 | "header": [{
1049 | "key": "Content-Type",
1050 | "value": "application/json",
1051 | "description": ""
1052 | },
1053 | {
1054 | "key": "//Authorization",
1055 | "value": "Token {{token}}",
1056 | "description": "",
1057 | "disabled": true
1058 | }
1059 | ],
1060 | "body": {},
1061 | "description": ""
1062 | },
1063 | "response": []
1064 | }]
1065 | }
1066 | ]
1067 | }
1068 |
--------------------------------------------------------------------------------
/postman/api.postman.env.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "410edddc-dbbf-5d80-9c82-759f9d13096b",
3 | "name": "Conduit",
4 | "values": [
5 | {
6 | "enabled": true,
7 | "key": "apiUrl",
8 | "value": "https://conduit.productionready.io/api",
9 | "type": "text"
10 | },
11 | {
12 | "enabled": true,
13 | "key": "token",
14 | "value": "",
15 | "type": "text"
16 | }
17 | ],
18 | "timestamp": 1489786196110,
19 | "_postman_variable_scope": "environment",
20 | "_postman_exported_at": "2017-03-17T21:30:01.555Z",
21 | "_postman_exported_using": "Postman/4.10.3"
22 | }
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/public/assets/ember.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/firstninja111/Simple-Ember-JS-DEMO/bb9f55e3a141c4723d09abc264281c8f97d41d87/public/assets/ember.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # http://www.robotstxt.org
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | [](http://realworld.io)
4 | [](https://github.com/gothinkster/ember-realworld/actions?query=workflow%3ACI)
5 | [](https://app.netlify.com/sites/ember-realworld/deploys)
6 |
7 | # Ember RealWorld example app
8 |
9 | > ### Ember.js codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API.
10 |
11 | ### [Demo](https://ember-realworld.netlify.com/) [RealWorld](https://github.com/gothinkster/realworld)
12 |
13 | ## Prerequisites
14 |
15 | You will need the following things properly installed on your computer.
16 |
17 | - [Git](https://git-scm.com/)
18 | - [Node.js](https://nodejs.org/) (with npm)
19 | - [Yarn](https://yarnpkg.com/)
20 | - [Ember CLI](https://ember-cli.com/)
21 | - [Google Chrome](https://google.com/chrome/)
22 |
23 | ## Installation
24 |
25 | - `git clone ` this repository
26 | - `cd ember-realworld`
27 | - `yarn`
28 |
29 | ## Running / Development
30 |
31 | - `ember serve`
32 | - Visit your app at [http://localhost:4200](http://localhost:4200).
33 | - Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests).
34 |
35 | ### Code Generators
36 |
37 | Make use of the many generators for code, try `ember help generate` for more details
38 |
39 | ### Running Tests
40 |
41 | - `yarn test`
42 | - `yarn test --server`
43 |
44 | ### Linting
45 |
46 | - `yarn lint`
47 | - `yarn lint:js --fix`
48 |
49 | ### Building
50 |
51 | - `ember build` (development)
52 | - `ember build --environment production` (production)
53 |
54 | ### Deploying
55 |
56 | This app is automatically deployed to [Netlify](https://www.netlify.com/).
57 |
58 | ## Further Reading / Useful Links
59 |
60 | - [ember.js](https://emberjs.com/)
61 | - [ember-cli](https://ember-cli.com/)
62 | - Development Browser Extensions
63 | - [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
64 | - [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
65 |
--------------------------------------------------------------------------------
/testem.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | test_page: 'tests/index.html?hidepassed',
5 | disable_watching: true,
6 | launch_in_ci: ['Chrome'],
7 | launch_in_dev: ['Chrome'],
8 | browser_start_timeout: 120,
9 | browser_args: {
10 | Chrome: {
11 | ci: [
12 | // --no-sandbox is needed when running Chrome inside a container
13 | process.env.CI ? '--no-sandbox' : null,
14 | '--headless',
15 | '--disable-dev-shm-usage',
16 | '--disable-software-rasterizer',
17 | '--mute-audio',
18 | '--remote-debugging-port=0',
19 | '--window-size=1440,900',
20 | ].filter(Boolean),
21 | },
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/tests/acceptance/article-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { click, visit, currentURL, fillIn, currentRouteName, settled } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedInUser } from '../helpers/user';
6 |
7 | module('Acceptance | article', function (hooks) {
8 | setupApplicationTest(hooks);
9 | setupMirage(hooks);
10 | setupLoggedInUser(hooks);
11 |
12 | let user;
13 |
14 | hooks.beforeEach(function () {
15 | user = this.server.create('user', {
16 | email: 'bob@example.com',
17 | password: 'password123',
18 | });
19 | this.server.get('/user', (schema) => {
20 | return schema.users.first();
21 | });
22 | });
23 |
24 | test('visiting /articles/:slug', async function (assert) {
25 | const profile = await this.server.create('profile');
26 | const article = await this.server.create('article', {
27 | author: profile,
28 | });
29 |
30 | await visit(`/articles/${article.slug}`);
31 |
32 | assert.equal(currentURL(), `/articles/${article.slug}`);
33 | });
34 |
35 | test('favorite article', async function (assert) {
36 | const profile = await this.server.create('profile');
37 | const article = await this.server.create('article', {
38 | author: profile,
39 | favorited: false,
40 | });
41 |
42 | await visit(`/articles/${article.slug}`);
43 |
44 | await click('[data-test-favorite-article-button]');
45 | // eslint-disable-next-line ember/no-settled-after-test-helper
46 | await settled();
47 |
48 | assert.ok(article.favorited, 'Expected article to be favorited');
49 |
50 | await click('[data-test-favorite-article-button]');
51 |
52 | assert.notOk(article.favorited, 'Expected article to be unfavorited');
53 | });
54 |
55 | test('follow author', async function (assert) {
56 | const profile = await this.server.create('profile', {
57 | following: false,
58 | });
59 | const article = await this.server.create('article', {
60 | author: profile,
61 | favorited: false,
62 | });
63 |
64 | await visit(`/articles/${article.slug}`);
65 |
66 | await click('[data-test-follow-author-button]');
67 | // eslint-disable-next-line ember/no-settled-after-test-helper
68 | await settled();
69 |
70 | assert.dom('[data-test-follow-author-button]').hasTextContaining('Unfollow');
71 |
72 | await click('[data-test-follow-author-button]');
73 | // eslint-disable-next-line ember/no-settled-after-test-helper
74 | await settled();
75 |
76 | assert.dom('[data-test-follow-author-button]').hasTextContaining('Follow');
77 | });
78 |
79 | test('edit article', async function (assert) {
80 | const userProfile = await this.server.schema.profiles.findBy({ username: user.username });
81 | const article = await this.server.create('article', {
82 | author: userProfile,
83 | });
84 |
85 | await visit(`/articles/${article.slug}`);
86 |
87 | await click('[data-test-edit-article-button]');
88 | assert.equal(
89 | currentRouteName(),
90 | 'editor.edit',
91 | 'Expect to transition to `editor.article` page to edit the article',
92 | );
93 | assert.dom('[data-test-article-form-input-title]').hasValue(article.title);
94 | });
95 |
96 | test('delete article', async function (assert) {
97 | assert.expect(1);
98 |
99 | const userProfile = await this.server.schema.profiles.findBy({ username: user.username });
100 | const article = await this.server.create('article', {
101 | author: userProfile,
102 | });
103 |
104 | await visit(`/articles/${article.slug}`);
105 |
106 | await click('[data-test-delete-article-button]');
107 |
108 | assert.equal(
109 | currentRouteName(),
110 | 'index',
111 | 'Expected to transition to index when article is deleted',
112 | );
113 | });
114 |
115 | test('post comment', async function (assert) {
116 | assert.expect(3);
117 |
118 | const profile = await this.server.create('profile');
119 | const article = await this.server.create('article', {
120 | author: profile,
121 | });
122 | const message = 'foo!';
123 |
124 | await visit(`/articles/${article.slug}`);
125 |
126 | assert.dom('[data-test-article-comment]').doesNotExist();
127 |
128 | await fillIn('[data-test-article-comment-textarea]', message);
129 | await click('[data-test-article-comment-button]');
130 |
131 | assert.dom('[data-test-article-comment]').exists({ count: 1 });
132 | assert.dom('[data-test-article-comment-body]').hasText('foo!');
133 | });
134 |
135 | test('delete comment', async function (assert) {
136 | assert.expect(2);
137 |
138 | const profile = await this.server.create('profile');
139 | const userProfile = await this.server.schema.profiles.findBy({
140 | username: user.username,
141 | });
142 | const article = await this.server.create('article', {
143 | author: profile,
144 | });
145 |
146 | await this.server.createList('comment', 1, {
147 | article,
148 | author: userProfile,
149 | });
150 |
151 | await visit(`/articles/${article.slug}`);
152 |
153 | assert.dom('[data-test-article-comment]').exists({ count: 1 });
154 |
155 | await click('[data-test-article-comment-delete-button]');
156 |
157 | assert.dom('[data-test-article-comment]').doesNotExist();
158 | });
159 | });
160 |
--------------------------------------------------------------------------------
/tests/acceptance/editor/article-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import {
3 | visit,
4 | currentURL,
5 | fillIn,
6 | click,
7 | triggerKeyEvent,
8 | currentRouteName,
9 | } from '@ember/test-helpers';
10 | import { setupApplicationTest } from 'ember-qunit';
11 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
12 | import { setupLoggedOutUser, setupLoggedInUser } from '../../helpers/user';
13 | import sinon from 'sinon';
14 |
15 | module('Acceptance | editor/edit', function (hooks) {
16 | setupApplicationTest(hooks);
17 | setupMirage(hooks);
18 |
19 | hooks.before(function () {
20 | sinon.stub(window, 'confirm');
21 | });
22 |
23 | hooks.after(function () {
24 | sinon.restore();
25 | });
26 |
27 | module('anonymous user', function (hooks) {
28 | setupLoggedOutUser(hooks);
29 |
30 | test('is transitioned to login', async function (assert) {
31 | await visit('/editor/foo');
32 |
33 | assert.equal(currentURL(), '/login');
34 | });
35 | });
36 |
37 | module('logged-in user', function (hooks) {
38 | setupLoggedInUser(hooks);
39 |
40 | let user;
41 | let userProfile;
42 | let article;
43 |
44 | hooks.beforeEach(function () {
45 | user = this.server.create('user');
46 | userProfile = this.server.schema.profiles.findBy({ username: user.username });
47 | article = this.server.create('article', {
48 | author: userProfile,
49 | });
50 | });
51 |
52 | test('can edit their own article', async function (assert) {
53 | await visit(`/editor/${article.slug}`);
54 | await fillIn('[data-test-article-form-input-title]', 'Test Title');
55 | await fillIn('[data-test-article-form-input-description]', 'Test Description');
56 | await fillIn('[data-test-article-form-input-body]', 'Test Body');
57 |
58 | await fillIn('[data-test-article-form-input-tags]', 'test-tag');
59 | await triggerKeyEvent('[data-test-article-form-input-tags]', 'keydown', 'Enter');
60 | await click('[data-test-article-form-submit-button]');
61 |
62 | assert.equal(currentRouteName(), 'articles.article');
63 | assert.dom('[data-test-article-title]').hasText('Test Title');
64 | assert.dom('[data-test-article-body]').hasText('Test Body');
65 | });
66 |
67 | test('shows article errors from server', async function (assert) {
68 | await visit(`/editor/${article.slug}`);
69 |
70 | await fillIn('[data-test-article-form-input-title]', 'Test Title');
71 | await fillIn('[data-test-article-form-input-description]', 'Test Description');
72 | await fillIn('[data-test-article-form-input-body]', '');
73 | await click('[data-test-article-form-submit-button]');
74 |
75 | assert
76 | .dom('[data-test-article-form-error-item]')
77 | .exists({ count: 1 }, 'A single error exists');
78 | assert.dom('[data-test-article-form-error-item]').hasText("body can't be blank");
79 | assert.equal(
80 | currentRouteName(),
81 | 'editor.edit',
82 | 'Should not navigate away from the page when there are errors',
83 | );
84 | });
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/tests/acceptance/editor/new-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import {
3 | visit,
4 | currentURL,
5 | fillIn,
6 | triggerKeyEvent,
7 | click,
8 | currentRouteName,
9 | } from '@ember/test-helpers';
10 | import { setupApplicationTest } from 'ember-qunit';
11 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
12 | import { setupLoggedOutUser, setupLoggedInUser } from '../../helpers/user';
13 | import sinon from 'sinon';
14 |
15 | module('Acceptance | editor', function (hooks) {
16 | setupApplicationTest(hooks);
17 | setupMirage(hooks);
18 |
19 | hooks.before(function () {
20 | sinon.stub(window, 'confirm');
21 | });
22 |
23 | hooks.after(function () {
24 | sinon.restore();
25 | });
26 |
27 | module('anonymous user', function (hooks) {
28 | setupLoggedOutUser(hooks);
29 |
30 | test('is transitioned to login', async function (assert) {
31 | await visit('/editor');
32 |
33 | assert.equal(currentURL(), '/login');
34 | });
35 | });
36 |
37 | module('logged-in user', function (hooks) {
38 | setupLoggedInUser(hooks);
39 |
40 | hooks.beforeEach(function () {
41 | this.server.create('user', {
42 | email: 'bob@example.com',
43 | password: 'password123',
44 | });
45 | });
46 |
47 | test('can create an article', async function (assert) {
48 | await visit('/editor');
49 |
50 | await fillIn('[data-test-article-form-input-title]', 'Test Title');
51 | await fillIn('[data-test-article-form-input-description]', 'Test Description');
52 | await fillIn('[data-test-article-form-input-body]', 'Test Body');
53 |
54 | await fillIn('[data-test-article-form-input-tags]', 'test-tag');
55 | await triggerKeyEvent('[data-test-article-form-input-tags]', 'keydown', 'Enter');
56 | await click('[data-test-article-form-submit-button]');
57 |
58 | assert.equal(currentRouteName(), 'articles.article');
59 | assert.dom('[data-test-article-title]').hasText('Test Title');
60 | assert.dom('[data-test-article-body]').hasText('Test Body');
61 | });
62 |
63 | test('shows article errors from server', async function (assert) {
64 | await visit('/editor');
65 |
66 | await fillIn('[data-test-article-form-input-title]', 'Test Title');
67 | await fillIn('[data-test-article-form-input-description]', 'Test Description');
68 | await fillIn('[data-test-article-form-input-body]', '');
69 | await click('[data-test-article-form-submit-button]');
70 |
71 | assert
72 | .dom('[data-test-article-form-error-item]')
73 | .exists({ count: 1 }, 'A single error exists');
74 | assert.dom('[data-test-article-form-error-item]').hasText("body can't be blank");
75 | assert.equal(
76 | currentRouteName(),
77 | 'editor.index',
78 | 'Should not navigate away from the page when there are errors',
79 | );
80 | });
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/tests/acceptance/error-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { visit } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedOutUser } from '../helpers/user';
6 |
7 | module('Acceptance | Error', function (hooks) {
8 | setupApplicationTest(hooks);
9 | setupMirage(hooks);
10 | setupLoggedOutUser(hooks);
11 |
12 | test('visiting /error', async function (assert) {
13 | await visit('/some-BODY-once-told-me');
14 |
15 | assert.dom('[data-test-error-page]').exists('displays error page content for invalid URLs');
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/tests/acceptance/index-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { visit, currentURL, click, settled } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedInUser, setupLoggedOutUser } from '../helpers/user';
6 |
7 | module('Acceptance | index', function (hooks) {
8 | setupApplicationTest(hooks);
9 | setupMirage(hooks);
10 | setupLoggedOutUser(hooks);
11 |
12 | test('visiting /', async function (assert) {
13 | this.server.createList('article', 20);
14 |
15 | await visit('/');
16 |
17 | assert.equal(currentURL(), '/', 'The home URL is correct');
18 | assert
19 | .dom('[data-test-article-preview]')
20 | .exists({ count: 10 }, 'The correct number of articles appear in the list');
21 | assert
22 | .dom('[data-test-tag]')
23 | .exists({ count: 7 }, 'The correct number of tags appear in the sidebar');
24 | assert
25 | .dom('[data-test-page-item]')
26 | .exists({ count: 2 }, 'The correct number of pages appear in the pagination list');
27 | assert.dom('[data-test-tab]').exists({ count: 1 }, 'The correct number of feed tabs appear');
28 | assert.dom('[data-test-feed="your"]').doesNotExist('Your feed is not shown when logged out');
29 | assert.dom('[data-test-tab="global"]').hasClass('active', 'The global tag is active');
30 | assert
31 | .dom('[data-test-page-item="1"]')
32 | .hasClass('active', 'The active page is correct in the pagination list');
33 | });
34 |
35 | test('clicking a page', async function (assert) {
36 | await this.server.createList('article', 20);
37 |
38 | await visit('/');
39 | assert
40 | .dom('[data-test-article-preview]')
41 | .exists({ count: 10 }, 'The correct number of articles appear in the list');
42 | await click('[data-test-page-item-link="2"]');
43 | assert
44 | .dom('[data-test-article-preview]')
45 | .exists(
46 | { count: 10 },
47 | 'After changing page the correct number of articles appear in the list',
48 | );
49 | assert.equal(currentURL(), '/?page=2');
50 | assert
51 | .dom('[data-test-page-item="2"]')
52 | .hasClass('active', 'The active page is correct in the pagination list');
53 | });
54 |
55 | test('clicking a tag', async function (assert) {
56 | await this.server.createList('article', 20);
57 |
58 | await visit('/');
59 | assert
60 | .dom('[data-test-article-preview]')
61 | .exists({ count: 10 }, 'The correct number of articles appear in the list');
62 | await click('[data-test-tag="emberjs"]');
63 |
64 | assert
65 | .dom('[data-test-article-preview]')
66 | .exists(
67 | { count: 10 },
68 | 'After changing page the correct number of articles appear in the list',
69 | );
70 | assert.equal(currentURL(), '/?tag=emberjs', 'The URL has the correct tag as a query param');
71 | assert
72 | .dom('.feed-toggle a.nav-link')
73 | .exists({ count: 2 }, 'The correct number of feed tabs appear');
74 | assert.dom('[data-test-tab="tag"]').hasClass('active', 'The tag feed toggle is active');
75 | assert
76 | .dom('[data-test-tab="tag"]')
77 | .hasText('#emberjs', 'The active feed toggle has the correct tag name');
78 | });
79 |
80 | test('resetting to the main list', async function (assert) {
81 | await this.server.createList('article', 20);
82 |
83 | await visit('/?page=2&tag=emberjs');
84 | assert
85 | .dom('[data-test-article-preview]')
86 | .exists({ count: 10 }, 'The correct number of articles appear in the list');
87 | await click('[data-test-tab="global"]');
88 |
89 | assert.equal(currentURL(), '/');
90 | assert
91 | .dom('[data-test-article-preview]')
92 | .exists(
93 | { count: 10 },
94 | 'After changing page the correct number of articles appear in the list',
95 | );
96 | assert.dom('[data-test-tab="global"]').hasClass('active', 'The global tag is active');
97 | assert.dom('[data-test-page-item="1"]').hasClass('active', 'The first page is active');
98 | });
99 |
100 | module('logged in user', function (hooks) {
101 | setupLoggedInUser(hooks);
102 |
103 | hooks.beforeEach(function () {
104 | this.server.create('user', {
105 | email: 'bob@example.com',
106 | password: 'password123',
107 | });
108 | this.server.get('/user', (schema) => {
109 | return schema.users.first();
110 | });
111 | });
112 |
113 | test('Your feed', async function (assert) {
114 | await this.server.createList('article', 20);
115 |
116 | this.server.get('/articles/feed', (schema) => {
117 | return {
118 | articles: [schema.articles.first()],
119 | articlesCount: 1,
120 | };
121 | });
122 |
123 | await visit('/');
124 | assert.equal(currentURL(), '/', 'Lands on the home page');
125 | assert
126 | .dom('[data-test-tab="global"]')
127 | .hasClass('active', 'Global feed is selected by default');
128 | assert
129 | .dom('[data-test-article-preview]')
130 | .exists({ count: 10 }, 'The correct articles are shown in the list');
131 | await click('[data-test-tab="your"]');
132 | // eslint-disable-next-line ember/no-settled-after-test-helper
133 | await settled();
134 |
135 | assert.equal(currentURL(), '/?feed=your', 'Lands on the "Your feed" page');
136 | assert
137 | .dom('[data-test-article-preview]')
138 | .exists({ count: 1 }, 'One article is loaded on "Your feed"');
139 | assert.dom('[data-test-tab="your"]').hasClass('active', 'Your feed is selected');
140 | });
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/tests/acceptance/login-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { visit, currentURL, fillIn, click, settled } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedOutUser } from '../helpers/user';
6 |
7 | module('Acceptance | login', function (hooks) {
8 | setupApplicationTest(hooks);
9 | setupMirage(hooks);
10 | setupLoggedOutUser(hooks);
11 |
12 | test('visiting /login', async function (assert) {
13 | const user = this.server.create('user', {
14 | email: 'bob@example.com',
15 | password: 'password123',
16 | });
17 |
18 | await visit('/login');
19 |
20 | await fillIn('[data-test-login-email]', user.email);
21 | await fillIn('[data-test-login-password]', user.password);
22 |
23 | await click('[data-test-login-button]');
24 | // eslint-disable-next-line ember/no-settled-after-test-helper
25 | await settled();
26 |
27 | assert.equal(currentURL(), '/', 'URL after login is Home');
28 | assert.dom('[data-test-nav-username]').hasText(user.username, 'Logged in username is shown');
29 | assert.dom('[data-test-nav-new-post]').exists('Logged in nav is shown');
30 | assert.dom('[data-test-nav-sign-up]').doesNotExist('Logged out nav is not shown');
31 | });
32 |
33 | test('visiting /login has link to /register', async function (assert) {
34 | await visit('/login');
35 |
36 | await click('[data-test-register-link]');
37 |
38 | assert.equal(currentURL(), '/register', 'URL after click is Register');
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/tests/acceptance/profile-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { visit, currentURL, click, settled } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedInUser, setupLoggedOutUser } from '../helpers/user';
6 |
7 | module('Acceptance | profile', function (hooks) {
8 | setupApplicationTest(hooks);
9 | setupMirage(hooks);
10 |
11 | module('logged out user', function (hooks) {
12 | setupLoggedOutUser(hooks);
13 |
14 | test('visiting /profile/username', async function (assert) {
15 | const profileOwner = this.server.create('profile');
16 | await visit(`/profile/${profileOwner.username}`);
17 |
18 | assert
19 | .dom('[data-test-edit-profile-button]')
20 | .doesNotExist('A logged-out user does not show the edit profile button');
21 | });
22 |
23 | test('visiting any user profile will see a link to follow the profile owner but links to login', async function (assert) {
24 | const profileOwner = this.server.create('profile');
25 |
26 | await visit(`/profile/${profileOwner.username}`);
27 | await click('[data-test-follow-author-button]');
28 |
29 | assert.equal(currentURL(), '/login');
30 | });
31 |
32 | test('sees a tab navigation to articles written by and favorited by the profile owner', async function (assert) {
33 | const profileOwner = this.server.create('profile');
34 | const otherUser = this.server.create('profile');
35 |
36 | /**
37 | * Articles written by profile owner.
38 | */
39 | this.server.createList('article', 2, {
40 | author: profileOwner,
41 | favorited: false,
42 | });
43 | /**
44 | * Articles favorited by profile owner, not written by profile owner.
45 | */
46 | this.server.createList('article', 3, {
47 | author: otherUser,
48 | favorited: true,
49 | });
50 |
51 | await visit(`/profile/${profileOwner.username}`);
52 | await click('[data-test-profile-tab="favorite-articles"]');
53 |
54 | assert.equal(currentURL(), `/profile/${profileOwner.username}/favorites`);
55 | assert
56 | .dom('[data-test-article-title]')
57 | .exists({ count: 3 }, 'Expected a list of 3 articles favorited by profile owner');
58 |
59 | await click('[data-test-profile-tab="my-articles"]');
60 | assert
61 | .dom('[data-test-article-title]')
62 | .exists({ count: 2 }, 'Expected a list of 2 articles created by profile owner');
63 | assert.equal(currentURL(), `/profile/${profileOwner.username}`);
64 | });
65 |
66 | test("clicking on an article's favorite button redirects user to login page", async function (assert) {
67 | const profileOwner = this.server.create('profile');
68 | /**
69 | * Articles written by profile owner.
70 | */
71 | this.server.create('article', 1, {
72 | author: profileOwner,
73 | favorited: false,
74 | });
75 |
76 | await visit(`/profile/${profileOwner.username}`);
77 | await click('[data-test-favorite-article-button]');
78 | assert.equal(currentURL(), '/login');
79 | });
80 | });
81 |
82 | module('logged in user', function (hooks) {
83 | setupLoggedInUser(hooks, 'token');
84 |
85 | let user;
86 |
87 | hooks.beforeEach(function () {
88 | user = this.server.create('user', {
89 | email: 'bob@example.com',
90 | password: 'password123',
91 | });
92 | this.server.get('/user', (schema) => {
93 | return schema.users.first();
94 | });
95 | });
96 |
97 | test('visiting their own profile sees a link to edit profile', async function (assert) {
98 | assert.expect(1);
99 |
100 | await visit(`/profile/${user.username}`);
101 | await click('[data-test-edit-profile-button]');
102 |
103 | assert.equal(currentURL(), `/settings`);
104 | });
105 |
106 | test('visiting another user profile sees a link to follow the profile owner', async function (assert) {
107 | const otherUser = this.server.create('profile', { following: false });
108 |
109 | await visit(`/profile/${otherUser.username}`);
110 | assert
111 | .dom('[data-test-follow-author-button]')
112 | .includesText(`Follow ${otherUser.username}`, 'The profile is initially unfollowed');
113 |
114 | await click('[data-test-follow-author-button]');
115 | // eslint-disable-next-line ember/no-settled-after-test-helper
116 | await settled();
117 |
118 | assert
119 | .dom('[data-test-follow-author-button]')
120 | .includesText(`Unfollow ${otherUser.username}`, 'The profile is followed');
121 |
122 | await click('[data-test-follow-author-button]');
123 | // eslint-disable-next-line ember/no-settled-after-test-helper
124 | await settled();
125 |
126 | assert
127 | .dom('[data-test-follow-author-button]')
128 | .includesText(`Follow ${otherUser.username}`, 'The profile is unfollowed');
129 | });
130 |
131 | test('favorite an article by the user', async function (assert) {
132 | const profileOwner = this.server.create('profile');
133 | this.server.create('article', 1, {
134 | author: profileOwner,
135 | favorited: false,
136 | });
137 |
138 | await visit(`/profile/${profileOwner.username}`);
139 | await click('[data-test-favorite-article-button]');
140 | // eslint-disable-next-line ember/no-settled-after-test-helper
141 | await settled();
142 |
143 | assert
144 | .dom('[data-test-favorite-article-button="favorited"]')
145 | .exists('Article should be favorited');
146 | });
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/tests/acceptance/register-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import faker from 'faker';
3 | import { visit, currentURL, fillIn, click } from '@ember/test-helpers';
4 | import { setupApplicationTest } from 'ember-qunit';
5 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
6 | import { setupLoggedOutUser } from '../helpers/user';
7 |
8 | module('Acceptance | register', function (hooks) {
9 | setupApplicationTest(hooks);
10 | setupMirage(hooks);
11 | setupLoggedOutUser(hooks);
12 |
13 | test('successful registration', async function (assert) {
14 | const user = this.server.create('user', {
15 | name: 'Test User',
16 | username: 'test_user',
17 | email: faker.internet.email(),
18 | password: 'password123',
19 | });
20 |
21 | await visit('/register');
22 |
23 | await fillIn('[data-test-register-username]', user.username);
24 | await fillIn('[data-test-register-email]', user.email);
25 | await fillIn('[data-test-register-password]', user.password);
26 |
27 | await click('[data-test-register-button]');
28 |
29 | assert.equal(currentURL(), '/', 'URL after login is Home');
30 | assert.dom('[data-test-nav-username]').hasText(user.username, 'Logged in username is shown');
31 | assert.dom('[data-test-nav-new-post]').exists('Logged in nav is shown');
32 | assert.dom('[data-test-nav-sign-up]').doesNotExist('Logged out nav is not shown');
33 | });
34 |
35 | test('visiting /register has link to /login', async function (assert) {
36 | await visit('/register');
37 |
38 | await click('[data-test-login-link]');
39 |
40 | assert.equal(currentURL(), '/login', 'URL after click is Login');
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/tests/acceptance/settings-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { find, fillIn, visit, currentURL, click, currentRouteName } from '@ember/test-helpers';
3 | import { setupApplicationTest } from 'ember-qunit';
4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
5 | import { setupLoggedInUser, setupLoggedOutUser } from '../helpers/user';
6 | import { all } from 'rsvp';
7 |
8 | module('Acceptance | settings', function (hooks) {
9 | setupApplicationTest(hooks);
10 | setupMirage(hooks);
11 |
12 | module('logged-out user', function () {
13 | setupLoggedOutUser(hooks);
14 |
15 | test('visiting /settings redirects to login', async function (assert) {
16 | await visit('/settings');
17 |
18 | assert.equal(currentURL(), '/login');
19 | });
20 | });
21 |
22 | module('logged-in user', function (hooks) {
23 | setupLoggedInUser(hooks, 'token');
24 |
25 | hooks.beforeEach(function () {
26 | this.server.create('user', {
27 | email: 'bob@example.com',
28 | password: 'password123',
29 | });
30 |
31 | this.server.get('/user', (schema) => {
32 | return schema.users.first();
33 | });
34 | });
35 |
36 | test('can edit their settings', async function (assert) {
37 | await visit('/settings');
38 |
39 | const newSettings = {
40 | image: 'image',
41 | bio: 'bio',
42 | username: 'username',
43 | password: 'password',
44 | email: 'email@email.com',
45 | };
46 |
47 | assert.notEqual(
48 | find('[data-test-settings-form-input-image]').value,
49 | newSettings.image,
50 | 'Settings image input should be different',
51 | );
52 | assert.notEqual(
53 | find('[data-test-settings-form-input-bio]').value,
54 | newSettings.bio,
55 | 'Settings bio input should be different',
56 | );
57 | assert.notEqual(
58 | find('[data-test-settings-form-input-username]').value,
59 | newSettings.username,
60 | 'Settings username input should be different',
61 | );
62 | assert.notEqual(
63 | find('[data-test-settings-form-input-password]').value,
64 | newSettings.password,
65 | 'Settings password input should be different',
66 | );
67 | assert.notEqual(
68 | find('[data-test-settings-form-input-email]').value,
69 | newSettings.email,
70 | 'Settings email input should be different',
71 | );
72 |
73 | const newSettingsEntries = Object.entries(newSettings);
74 | await all(
75 | newSettingsEntries.map(([key, value]) => {
76 | return fillIn(`[data-test-settings-form-input-${key}]`, value);
77 | }),
78 | );
79 |
80 | await click('[data-test-settings-form-button]');
81 |
82 | assert
83 | .dom('[data-test-settings-form-input-image]')
84 | .hasValue(newSettings.image, 'Settings image input should be updated');
85 | assert
86 | .dom('[data-test-settings-form-input-bio]')
87 | .hasValue(newSettings.bio, 'Settings bio input should be updated');
88 | assert
89 | .dom('[data-test-settings-form-input-username]')
90 | .hasValue(newSettings.username, 'Settings username input should be updated');
91 | assert
92 | .dom('[data-test-settings-form-input-password]')
93 | .hasValue(newSettings.password, 'Settings password input should be updated');
94 | assert
95 | .dom('[data-test-settings-form-input-email]')
96 | .hasValue(newSettings.email, 'Settings email input should be updated');
97 | });
98 |
99 | test('shows settings errors from server', async function (assert) {
100 | await visit('/settings');
101 |
102 | await fillIn('[data-test-settings-form-input-username]', Array(22).join('a'));
103 | await fillIn('[data-test-settings-form-input-email]', '');
104 |
105 | await click('[data-test-settings-form-button]');
106 |
107 | assert
108 | .dom('[data-test-settings-form-error-item]')
109 | .exists({ count: 2 }, 'Two errors are visible');
110 | assert
111 | .dom('[data-test-settings-form-error-item="0"]')
112 | .hasText('username is too long (maximum is 20 characters)');
113 | assert.dom('[data-test-settings-form-error-item="1"]').hasText("email can't be blank");
114 | assert.equal(
115 | currentRouteName(),
116 | 'settings',
117 | 'Should not navigate away from the page when there are errors',
118 | );
119 | });
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/tests/helpers/user.js:
--------------------------------------------------------------------------------
1 | import SessionService from 'ember-realworld/services/session';
2 |
3 | export const TOKEN = 'auth-token';
4 |
5 | export function setupLoggedInUser(hooks, token = TOKEN) {
6 | const originalToken = localStorage.getItem(SessionService.STORAGE_KEY);
7 |
8 | hooks.beforeEach(function () {
9 | localStorage.setItem(SessionService.STORAGE_KEY, token || '');
10 | });
11 |
12 | hooks.afterEach(function () {
13 | localStorage.setItem(SessionService.STORAGE_KEY, originalToken || '');
14 | });
15 | }
16 |
17 | export function setupLoggedOutUser(hooks) {
18 | const originalToken = localStorage.getItem(SessionService.STORAGE_KEY);
19 |
20 | hooks.beforeEach(function () {
21 | localStorage.removeItem(SessionService.STORAGE_KEY);
22 | });
23 |
24 | hooks.afterEach(function () {
25 | localStorage.setItem(SessionService.STORAGE_KEY, originalToken || '');
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/tests/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | RealworldEmber Tests
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{content-for "head"}}
14 | {{content-for "test-head"}}
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{content-for "head-footer"}}
22 | {{content-for "test-head-footer"}}
23 |
24 |
25 | {{content-for "body"}}
26 | {{content-for "test-body"}}
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | {{content-for "body-footer"}}
42 | {{content-for "test-body-footer"}}
43 |
44 |
45 |
--------------------------------------------------------------------------------
/tests/integration/helpers/format-date-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { setupRenderingTest } from 'ember-qunit';
3 | import { render } from '@ember/test-helpers';
4 | import hbs from 'htmlbars-inline-precompile';
5 |
6 | module('Integration | Helper | format-date', function (hooks) {
7 | setupRenderingTest(hooks);
8 |
9 | test('it correctly formats the date', async function (assert) {
10 | this.set('inputValue', '2019-03-27T17:41:33.076Z');
11 |
12 | await render(hbs`{{format-date inputValue}}`);
13 |
14 | assert.dom(this.element).hasText('March 27, 2019');
15 | });
16 |
17 | test('it handles invalid inputs', async function (assert) {
18 | this.set('inputValue', null);
19 |
20 | await render(hbs`{{format-date inputValue}}`);
21 |
22 | assert.dom(this.element).hasText('');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/tests/test-helper.js:
--------------------------------------------------------------------------------
1 | import Application from 'ember-realworld/app';
2 | import config from 'ember-realworld/config/environment';
3 | import * as QUnit from 'qunit';
4 | import { setApplication } from '@ember/test-helpers';
5 | import { setup } from 'qunit-dom';
6 | import { start } from 'ember-qunit';
7 |
8 | setApplication(Application.create(config.APP));
9 |
10 | setup(QUnit.assert);
11 |
12 | start();
13 |
--------------------------------------------------------------------------------