├── .editorconfig
├── .gitignore
├── .husky
└── pre-commit
├── .prettierrc
├── LICENSE.md
├── README.md
├── cypress.json
├── cypress
├── README.md
├── fixtures
│ └── example.json
├── integration
│ ├── article-spec.js
│ ├── editor-spec.js
│ ├── home-spec.js
│ ├── login-spec.js
│ ├── navigation-spec.js
│ ├── profile-spec.js
│ ├── register-spec.js
│ └── settings-spec.js
├── plugins
│ └── index.js
└── support
│ └── index.js
├── package.json
├── project-logo.png
├── public
├── favicon.ico
└── index.html
├── src
├── agent.js
├── app
│ ├── history.js
│ ├── middleware.js
│ └── store.js
├── common
│ └── utils.js
├── components
│ ├── App.js
│ ├── Article
│ │ ├── ArticleActions.js
│ │ ├── ArticleMeta.js
│ │ └── index.js
│ ├── ArticleList.js
│ ├── ArticlePreview.js
│ ├── Editor.js
│ ├── Header.js
│ ├── Home
│ │ ├── Banner.js
│ │ ├── MainView.js
│ │ └── index.js
│ ├── ListErrors.js
│ ├── ListPagination.js
│ └── Profile.js
├── features
│ ├── auth
│ │ ├── AuthScreen.js
│ │ ├── AuthScreen.spec.js
│ │ ├── SettingsScreen.js
│ │ ├── SettingsScreen.spec.js
│ │ ├── authSlice.js
│ │ └── authSlice.spec.js
│ ├── comments
│ │ ├── CommentList.js
│ │ ├── CommentSection.js
│ │ ├── CommentSection.spec.js
│ │ ├── commentsSlice.js
│ │ └── commentsSlice.spec.js
│ └── tags
│ │ ├── TagsList.js
│ │ ├── TagsSidebar.js
│ │ ├── TagsSidebar.spec.js
│ │ ├── tagsSlice.js
│ │ └── tagsSlice.spec.js
├── index.js
├── reducers
│ ├── article.js
│ ├── articleList.js
│ ├── common.js
│ └── profile.js
├── setupTests.js
└── test
│ └── utils.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | coverage
8 | .nyc_output
9 |
10 | # production
11 | build
12 |
13 | # misc
14 | .DS_Store
15 | .env
16 | npm-debug.log
17 | .idea
18 |
19 | .eslintcache
20 |
21 | cypress/videos
22 | cypress/screenshots
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx pretty-quick --staged
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 GoThinkster
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 | [](https://app.netlify.com/sites/react-redux-realworld/deploys)
3 | [](http://realworld.io)
4 |
5 | > ### React + Redux 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.
6 |
7 |
8 |
9 | ### [Demo](https://react-redux-realworld.netlify.app/) [RealWorld](https://github.com/gothinkster/realworld)
10 |
11 | Originally created for this [GH issue](https://github.com/reactjs/redux/issues/1353). The codebase is now feature complete; please submit bug fixes via pull requests & feedback via issues.
12 |
13 | We also have notes in [**our wiki**](https://github.com/khaledosman/react-redux-realworld-example-app/wiki) about how the various patterns used in this codebase and how they work (thanks [@thejmazz](https://github.com/thejmazz)!)
14 |
15 |
16 | ## Getting started
17 |
18 | You can view a live demo over at https://react-redux-realworld.netlify.app/
19 |
20 | To get the frontend running locally:
21 |
22 | - Clone this repo
23 | - `npm install` to install all req'd dependencies
24 | - `npm start` to start the local server (this project uses create-react-app)
25 |
26 | Local web server will use port 4100 instead of standard React's port 3000 to prevent conflicts with some backends like Node or Rails. You can configure port in scripts section of `package.json`: we use [cross-env](https://github.com/kentcdodds/cross-env) to set environment variable PORT for React scripts, this is Windows-compatible way of setting environment variables.
27 |
28 | Alternatively, you can add `.env` file in the root folder of project to set environment variables (use PORT to change webserver's port). This file will be ignored by git, so it is suitable for API keys and other sensitive stuff. Refer to [dotenv](https://github.com/motdotla/dotenv) and [React](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-development-environment-variables-in-env) documentation for more details. Also, please remove setting variable via script section of `package.json` - `dotenv` never override variables if they are already set.
29 |
30 | ### Making requests to the backend API
31 |
32 | For convenience, we have a live API server running at https://api.realworld.io/api for the application to make requests against. You can view [the API spec here](https://github.com/GoThinkster/productionready/blob/master/api) which contains all routes & responses for the server.
33 |
34 | The source code for the backend server (available for Node, Rails and Django) can be found in the [main RealWorld repo](https://github.com/gothinkster/realworld).
35 |
36 | If you want to change the API URL to a local server, start/build the app with the REACT_APP_BACKEND_URL environment variable pointing to the local server's URL (i.e. `REACT_APP_BACKEND_URL="http://localhost:3000/api" npm run build`)
37 |
38 |
39 | ## Functionality overview
40 |
41 | The example application is a social blogging site (i.e. a Medium.com clone) called "Conduit". It uses a custom API for all requests, including authentication. You can view a live demo over at https://redux.productionready.io/
42 |
43 | **General functionality:**
44 |
45 | - Authenticate users via JWT (login/signup pages + logout button on settings page)
46 | - CRU* users (sign up & settings page - no deleting required)
47 | - CRUD Articles
48 | - CR*D Comments on articles (no updating required)
49 | - GET and display paginated lists of articles
50 | - Favorite articles
51 | - Follow other users
52 |
53 | **The general page breakdown looks like this:**
54 |
55 | - Home page (URL: /#/ )
56 | - List of tags
57 | - List of articles pulled from either Feed, Global, or by Tag
58 | - Pagination for list of articles
59 | - Sign in/Sign up pages (URL: /#/login, /#/register )
60 | - Use JWT (store the token in localStorage)
61 | - Settings page (URL: /#/settings )
62 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
63 | - Article page (URL: /#/article/article-slug-here )
64 | - Delete article button (only shown to article's author)
65 | - Render markdown from server client side
66 | - Comments section at bottom of page
67 | - Delete comment button (only shown to comment's author)
68 | - Profile page (URL: /#/@username, /#/@username/favorites )
69 | - Show basic user info
70 | - List of articles populated from author's created articles or author's favorited articles
71 |
72 |
73 |
74 | [](https://thinkster.io)
75 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:4100",
3 | "env": {
4 | "apiUrl": "https://conduit.productionready.io/api",
5 | "username": "warren_boyd",
6 | "email": "warren.boyd@mailinator.com",
7 | "password": "Pa$$w0rd!"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/cypress/README.md:
--------------------------------------------------------------------------------
1 | # Cypress.io end-to-end tests 🚀
2 |
3 | [Cypress.io](https://www.cypress.io) is an open source, MIT licensed end-to-end test runner
4 |
5 | ## Folder structure
6 |
7 | These folders hold the end-to-end tests and supporting files for the [Cypress Test Runner](https://github.com/cypress-io/cypress).
8 |
9 | - [fixtures](fixtures) folder holds optional JSON data for mocking, [read more](https://on.cypress.io/fixture)
10 | - [integration](integration) holds the actual test files, [read more](https://on.cypress.io/writing-and-organizing-tests)
11 | - [plugins](plugins) allow you to customize how tests are loaded, [read more](https://on.cypress.io/plugins)
12 | - [support](support) file runs before all tests and is a great place to write or load additional custom commands, [read more](https://on.cypress.io/writing-and-organizing-tests#Support-file)
13 |
14 | ## `cypress.json` file
15 |
16 | You can configure project options in the [../cypress.json](../cypress.json) file, see [Cypress configuration doc](https://on.cypress.io/configuration).
17 |
18 | ## More information
19 |
20 | - [https://github.com/cypress.io/cypress](https://github.com/cypress.io/cypress)
21 | - [https://docs.cypress.io/](https://docs.cypress.io/)
22 | - [Writing your first Cypress test](https://on.cypress.io/intro)
23 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/cypress/integration/article-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | describe('Article page', () => {
8 | beforeEach(() => {
9 | cy.intercept('GET', '**/articles?*')
10 | .as('getAllArticles')
11 | .intercept('GET', '**/articles/*/comments')
12 | .as('getCommentsForArticle')
13 | .intercept('GET', '**/articles/*')
14 | .as('getArticle')
15 | .visit('/');
16 |
17 | cy.wait('@getAllArticles').get('.preview-link').first().click();
18 | });
19 |
20 | it('should show the article', () => {
21 | cy.wait('@getArticle')
22 | .its('response.body.article')
23 | .then((article) => {
24 | cy.location('pathname').should('equal', `/article/${article.slug}`);
25 |
26 | cy.findByRole('heading', { name: article.title }).should('be.visible');
27 |
28 | cy.get('.article-meta').within(() => {
29 | cy.findByRole('img', { name: article.author.username }).should(
30 | 'be.visible'
31 | );
32 |
33 | cy.findByText(article.author.username).should('be.visible');
34 | });
35 |
36 | cy.get('.article-content .col-xs-12')
37 | .children()
38 | .first()
39 | .should('not.be.empty');
40 |
41 | cy.get('.tag-list')
42 | .children()
43 | .should('have.length', article.tagList.length);
44 | });
45 | });
46 |
47 | it('should require to be logged to comment', () => {
48 | cy.findByText(/to add comments on this article/i).should('be.visible');
49 | });
50 | });
51 |
52 | describe('Article page (authenticated)', () => {
53 | const commentPlaceholder = 'Write a comment...';
54 | const postCommentButton = 'Post Comment';
55 |
56 | beforeEach(() => {
57 | cy.intercept('GET', '**/articles?*')
58 | .as('getAllArticles')
59 | .intercept('GET', '**/articles/*/comments')
60 | .as('getCommentsForArticle')
61 | .intercept('GET', '**/articles/*')
62 | .as('getArticle')
63 | .intercept('POST', '**/articles/*/comments')
64 | .as('createComment')
65 | .intercept('DELETE', '**/articles/*/comments/*')
66 | .as('deleteComment')
67 | .visit('/')
68 | .login();
69 |
70 | cy.wait('@getAllArticles').get('.preview-link').first().click();
71 | });
72 |
73 | it('should show the comment box', () => {
74 | cy.wait(['@getArticle', '@getCommentsForArticle']);
75 |
76 | cy.get('.comment-form').should('exist');
77 | });
78 |
79 | it('should add a new comment', () => {
80 | const comment = faker.lorem.paragraph();
81 |
82 | cy.wait(['@getArticle', '@getCommentsForArticle']);
83 |
84 | cy.findByPlaceholderText(commentPlaceholder).type(comment);
85 |
86 | cy.findByRole('button', { name: postCommentButton }).click();
87 |
88 | cy.wait('@createComment').its('response.statusCode').should('equal', 200);
89 |
90 | cy.wait(100)
91 | .get('.card:not(form)')
92 | .first()
93 | .within(() => {
94 | cy.findByText(comment).should('exist');
95 | });
96 | });
97 |
98 | it('should validate the comment box', () => {
99 | cy.wait(['@getArticle', '@getCommentsForArticle']);
100 |
101 | cy.findByRole('button', { name: postCommentButton }).click();
102 |
103 | cy.wait('@createComment').its('response.statusCode').should('equal', 422);
104 |
105 | cy.get('.error-messages').within(() => {
106 | cy.findAllByRole('listitem').should('have.length', 1);
107 | });
108 | });
109 |
110 | it('should remove my own comment', () => {
111 | const comment = faker.lorem.sentence();
112 |
113 | cy.wait(['@getArticle', '@getCommentsForArticle']);
114 |
115 | cy.findByPlaceholderText(commentPlaceholder).type(comment);
116 |
117 | cy.findByRole('button', { name: postCommentButton }).click();
118 |
119 | cy.wait('@createComment');
120 |
121 | cy.findByText(comment)
122 | .as('comment')
123 | .parent()
124 | .parent()
125 | .find('.mod-options i')
126 | .click();
127 |
128 | cy.wait('@deleteComment').its('response.statusCode').should('equal', 200);
129 |
130 | cy.findByText(comment).should('not.exist');
131 | });
132 | });
133 |
134 | describe('Article page (author)', () => {
135 | const commentPlaceholder = 'Write a comment...';
136 | const postCommentButton = 'Post Comment';
137 |
138 | beforeEach(() => {
139 | cy.intercept('GET', '**/articles/*')
140 | .as('getArticle')
141 | .intercept('GET', '**/articles/*/comments')
142 | .as('getCommentsForArticle')
143 | .intercept('POST', '**/articles/*/comments')
144 | .as('createComment')
145 | .intercept('DELETE', '**/articles/*')
146 | .as('deleteArticle');
147 |
148 | cy.visit('/')
149 | .login()
150 | .createArticle()
151 | .then((article) => {
152 | cy.visit(`/article/${article.slug}`);
153 |
154 | cy.wait(['@getArticle', '@getCommentsForArticle']);
155 | });
156 | });
157 |
158 | it('should add a new comment', () => {
159 | const comment = faker.lorem.paragraph();
160 |
161 | cy.findByPlaceholderText(commentPlaceholder).type(comment);
162 |
163 | cy.findByRole('button', { name: postCommentButton }).click();
164 |
165 | cy.wait('@createComment').its('response.statusCode').should('equal', 200);
166 | });
167 |
168 | it('should remove my article', () => {
169 | cy.findByRole('button', {
170 | name: /delete article/i,
171 | }).click();
172 |
173 | cy.wait('@deleteArticle').its('response.statusCode').should('equal', 200);
174 |
175 | cy.location('pathname').should('equal', '/');
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/cypress/integration/editor-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | import { createArticle } from '../../src/reducers/article';
8 |
9 | const titlePlaceholder = 'Article Title';
10 | const descriptionPlaceholder = "What's this article about?";
11 | const bodyPlaceholder = 'Write your article (in markdown)';
12 | const tagPlaceholder = 'Enter tags';
13 | const submitButton = 'Publish Article';
14 |
15 | describe('New article', () => {
16 | beforeEach(() => {
17 | cy.visit('/').login();
18 |
19 | cy.intercept('POST', '**/articles').as('createArticle');
20 |
21 | cy.findByRole('link', {
22 | name: /new post/i,
23 | }).click();
24 | });
25 |
26 | it('should submit a new article', () => {
27 | cy.findByPlaceholderText(titlePlaceholder).type(faker.lorem.words());
28 |
29 | cy.findByPlaceholderText(descriptionPlaceholder).type(
30 | faker.lorem.sentence(),
31 | { delay: 1 }
32 | );
33 |
34 | cy.findByPlaceholderText(bodyPlaceholder).type(faker.lorem.paragraphs(), {
35 | delay: 1,
36 | });
37 |
38 | cy.findByPlaceholderText(tagPlaceholder).type(
39 | 'react{enter}redux{enter}lorem ipsum{enter}'
40 | );
41 |
42 | cy.findByRole('button', { name: submitButton }).click();
43 |
44 | cy.wait('@createArticle').its('response.statusCode').should('equal', 200);
45 |
46 | cy.location('pathname').should('match', /\/article\/[\w-]+/);
47 | });
48 |
49 | it('should validate the form', () => {
50 | cy.findByRole('button', { name: submitButton }).click();
51 |
52 | cy.wait('@createArticle').its('response.statusCode').should('equal', 422);
53 |
54 | cy.get('.error-messages').within(() => {
55 | cy.findAllByRole('listitem').should('have.length', 3);
56 | });
57 | });
58 |
59 | it('should add or remove tags', () => {
60 | cy.get('.tag-list').as('tagList').should('be.empty');
61 |
62 | cy.findByPlaceholderText(tagPlaceholder).type(
63 | 'lorem{enter}ipsum{enter}dolor{enter}sit{enter}amet{enter}'
64 | );
65 |
66 | cy.get('@tagList').children().should('have.length', 5);
67 |
68 | cy.get('@tagList').within(() => {
69 | cy.findByText('dolor').find('i').click();
70 | cy.findByText('sit').find('i').click();
71 | });
72 |
73 | cy.get('@tagList').children().should('have.length', 3);
74 | });
75 | });
76 |
77 | describe('Edit article', () => {
78 | let article;
79 |
80 | before(() => {
81 | cy.intercept('GET', '**/articles/*')
82 | .as('getArticle')
83 | .intercept('GET', '**/articles/*/comments')
84 | .as('getCommentsForArticle');
85 |
86 | cy.visit('/')
87 | .login()
88 | .createArticle()
89 | .then((newArticle) => {
90 | article = newArticle;
91 |
92 | cy.dispatch({
93 | type: createArticle.fulfilled.type,
94 | payload: { article },
95 | }).wait(['@getArticle', '@getCommentsForArticle']);
96 |
97 | cy.findByRole('link', {
98 | name: /edit article/i,
99 | }).click();
100 |
101 | cy.wait('@getArticle');
102 | });
103 | });
104 |
105 | it("should fill the form with article's data", () => {
106 | cy.findByPlaceholderText(titlePlaceholder).should(
107 | 'have.value',
108 | article.title
109 | );
110 |
111 | cy.findByPlaceholderText(descriptionPlaceholder).should(
112 | 'have.value',
113 | article.description
114 | );
115 |
116 | cy.findByPlaceholderText(bodyPlaceholder).should(
117 | 'have.value',
118 | article.body
119 | );
120 |
121 | cy.findByPlaceholderText(tagPlaceholder).should('have.value', '');
122 |
123 | cy.get('.tag-list')
124 | .children()
125 | .should('have.length', article.tagList.length);
126 |
127 | cy.get('.tag-list').within(() => {
128 | Cypress._.each(article.tagList, (tag) => {
129 | cy.findByText(RegExp(`^${tag}$`, 'i')).should('exist');
130 | });
131 | });
132 | });
133 |
134 | it('should update the article', () => {
135 | const description = faker.lorem.paragraph();
136 |
137 | cy.intercept('PUT', '**/articles/*').as('updateArticle');
138 |
139 | cy.findByPlaceholderText(descriptionPlaceholder).clear().type(description);
140 |
141 | cy.get('.tag-list').within(() => {
142 | Cypress._.each(article.tagList, (tag) => {
143 | cy.findByText(RegExp(`^${tag}$`, 'i'))
144 | .find('i')
145 | .click();
146 | });
147 | });
148 |
149 | cy.findByPlaceholderText(tagPlaceholder).type(
150 | 'react{enter}redux{enter}markdown{enter}lorem ipsum{enter}'
151 | );
152 |
153 | cy.findByRole('button', { name: submitButton }).click();
154 |
155 | cy.wait('@updateArticle')
156 | .its('response')
157 | .then((response) => {
158 | expect(response.statusCode).to.equal(200);
159 |
160 | expect(response.body.article).to.haveOwnProperty(
161 | 'description',
162 | description
163 | );
164 | expect(response.body.article).to.haveOwnProperty('tagList');
165 | expect(response.body.article.tagList).to.deep.equal([
166 | 'react',
167 | 'redux',
168 | 'markdown',
169 | 'lorem ipsum',
170 | ]);
171 | });
172 |
173 | cy.location('pathname').should('match', /\/article\/[\w-]+/);
174 | });
175 | });
176 |
--------------------------------------------------------------------------------
/cypress/integration/home-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 |
6 | describe('Home page', () => {
7 | beforeEach(() => {
8 | cy.intercept('**/articles?**')
9 | .as('getAllArticles')
10 | .intercept('**/tags')
11 | .as('getAllTags')
12 | .visit('/');
13 | });
14 |
15 | it('should show the app name', () => {
16 | cy.get('.banner').within(() => {
17 | cy.findByRole('heading', { level: 1, name: /conduit/i }).should(
18 | 'be.visible'
19 | );
20 |
21 | cy.findByText('A place to share your knowledge.').should('be.visible');
22 | });
23 | });
24 |
25 | it('should have a header navbar', () => {
26 | cy.findByRole('navigation').within(() => {
27 | cy.findByRole('link', { name: /conduit/i }).should(
28 | 'have.attr',
29 | 'href',
30 | '/'
31 | );
32 |
33 | cy.findByRole('link', { name: /home/i }).should('have.attr', 'href', '/');
34 |
35 | cy.findByRole('link', { name: /sign in/i }).should(
36 | 'have.attr',
37 | 'href',
38 | '/login'
39 | );
40 | cy.findByRole('link', { name: /sign up/i }).should(
41 | 'have.attr',
42 | 'href',
43 | '/register'
44 | );
45 | });
46 | });
47 |
48 | it('should render the list of articles', () => {
49 | cy.findByRole('button', { name: /global feed/i }).should('be.visible');
50 |
51 | cy.wait('@getAllArticles')
52 | .its('response.body')
53 | .then((body) => {
54 | cy.get('.article-preview').should('have.length', body.articles.length);
55 |
56 | Cypress._.each(body.articles, (article, index) => {
57 | cy.get('.article-preview')
58 | .eq(index)
59 | .within(() => {
60 | cy.findByRole('img', { name: article.author.username });
61 |
62 | cy.findByText(article.author.username);
63 |
64 | cy.findByRole('heading').should('have.text', article.title);
65 |
66 | cy.get('p').should('have.text', article.description);
67 |
68 | cy.findByRole('list')
69 | .children()
70 | .should('have.length', article.tagList.length);
71 |
72 | cy.findByRole('list').within(() => {
73 | Cypress._.each(article.tagList, (tag) => {
74 | cy.findByText(tag);
75 | });
76 | });
77 | });
78 | });
79 | });
80 | });
81 |
82 | it('should render the list of tags', () => {
83 | cy.wait('@getAllTags')
84 | .its('response.body')
85 | .then((body) => {
86 | cy.get('.sidebar').within(() => {
87 | cy.findByText('Popular Tags');
88 |
89 | cy.findAllByRole('button').should('have.length', body.tags.length);
90 | });
91 | });
92 | });
93 |
94 | it('should show the pagination', () => {
95 | cy.wait('@getAllArticles')
96 | .its('response.body')
97 | .then((body) => {
98 | const pages = Math.floor(body.articlesCount / body.articles.length);
99 |
100 | cy.get('.pagination').within(() => {
101 | cy.findAllByRole('listitem').should('have.length.at.most', pages);
102 | });
103 | });
104 | });
105 | });
106 |
107 | describe('Home page (authenticated)', () => {
108 | beforeEach(() => {
109 | cy.task('createUserWithArticle', { followUser: true });
110 |
111 | cy.intercept('**/articles?*')
112 | .as('getAllArticles')
113 | .intercept('**/articles/feed?*')
114 | .as('getAllFeed')
115 | .intercept('**/tags')
116 | .as('getAllTags')
117 | .intercept('POST', '**/articles/*/favorite')
118 | .as('favoriteArticle')
119 | .intercept('DELETE', '**/articles/*/favorite')
120 | .as('unfavoriteArticle')
121 | .visit('/')
122 | .login();
123 | });
124 |
125 | it('should mark an article as favorite', () => {
126 | cy.findByRole('button', {
127 | name: /global feed/i,
128 | }).click();
129 |
130 | cy.wait('@getAllArticles')
131 | .its('response.body.articles')
132 | .then((articles) => {
133 | const article = articles[0];
134 |
135 | cy.findAllByRole('heading', { name: article.title })
136 | .first()
137 | .parent()
138 | .parent()
139 | .find('.article-meta')
140 | .within(() => {
141 | cy.findByRole('button').click();
142 | });
143 | });
144 |
145 | cy.wait('@favoriteArticle').its('response.statusCode').should('equal', 200);
146 | });
147 |
148 | it('should mark an article as unfavorite', () => {
149 | cy.findByRole('button', {
150 | name: /global feed/i,
151 | }).click();
152 |
153 | cy.wait('@getAllArticles')
154 | .its('response.body.articles')
155 | .then((articles) => {
156 | const article = articles[1];
157 |
158 | cy.findAllByRole('heading', { name: article.title })
159 | .first()
160 | .parent()
161 | .parent()
162 | .find('.article-meta')
163 | .within(() => {
164 | cy.findByRole('button').click();
165 |
166 | cy.wait('@favoriteArticle');
167 |
168 | cy.findByRole('button').click();
169 | });
170 | });
171 |
172 | cy.wait('@unfavoriteArticle')
173 | .its('response.statusCode')
174 | .should('equal', 200);
175 | });
176 | });
177 |
--------------------------------------------------------------------------------
/cypress/integration/login-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | describe('Login page', () => {
8 | const emailPlaceholder = 'Email';
9 | const passwordPlaceholder = 'Password';
10 |
11 | beforeEach(() => {
12 | cy.intercept('POST', '**/users/login').as('login').visit('/login');
13 | });
14 |
15 | it('should submit the login form', () => {
16 | cy.findByPlaceholderText(emailPlaceholder).type(Cypress.env('email'));
17 |
18 | cy.findByPlaceholderText(passwordPlaceholder).type(Cypress.env('password'));
19 |
20 | cy.findByRole('button', { name: /sign in/i }).click();
21 |
22 | cy.wait('@login').its('response.statusCode').should('equal', 200);
23 |
24 | cy.location('pathname').should('be.equal', '/');
25 |
26 | cy.findByRole('navigation').within(() => {
27 | cy.findByRole('link', { name: RegExp(Cypress.env('username'), 'i') });
28 | });
29 | });
30 |
31 | it('should require all the fields', () => {
32 | cy.findByRole('button', { name: /sign in/i }).click();
33 |
34 | cy.wait('@login').its('response.statusCode').should('equal', 422);
35 |
36 | cy.get('.error-messages').within(() => {
37 | cy.findAllByRole('listitem').should('have.length', 1);
38 | });
39 | });
40 |
41 | it('should validate the email and password', () => {
42 | cy.findByPlaceholderText(emailPlaceholder).type(Cypress.env('email'));
43 |
44 | cy.findByPlaceholderText(passwordPlaceholder).type(
45 | faker.internet.password()
46 | );
47 |
48 | cy.findByRole('button', { name: /sign in/i }).click();
49 |
50 | cy.wait('@login').its('response.statusCode').should('equal', 422);
51 |
52 | cy.get('.error-messages').within(() => {
53 | cy.findByRole('listitem').should(
54 | 'have.text',
55 | 'email or password is invalid'
56 | );
57 | });
58 | });
59 |
60 | it('should navigate to register page', () => {
61 | cy.findByRole('link', { name: /need an account/i }).click();
62 |
63 | cy.location('pathname').should('be.equal', '/register');
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/cypress/integration/navigation-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 |
6 | describe('Navigation', () => {
7 | beforeEach(() => {
8 | cy.intercept('**/articles?*')
9 | .as('getAllArticles')
10 | .intercept('**/tags')
11 | .as('getAllTags')
12 | .intercept('**/articles/*/comments')
13 | .as('getCommentsForArticle')
14 | .intercept('**/articles/*')
15 | .as('getArticle')
16 | .visit('/');
17 | });
18 |
19 | it('should navigate to the login page', () => {
20 | cy.findByRole('link', { name: /sign in/i }).click();
21 |
22 | cy.location('pathname').should('be.equal', '/login');
23 |
24 | cy.findByRole('heading', { name: /sign in/i });
25 | });
26 |
27 | it('should navigate to the register page', () => {
28 | cy.findByRole('link', { name: /sign up/i }).click();
29 |
30 | cy.location('pathname').should('be.equal', '/register');
31 |
32 | cy.findByRole('heading', { name: /sign up/i });
33 | });
34 |
35 | it('should navigate to the first article', () => {
36 | cy.get('.preview-link')
37 | .first()
38 | .within(() => {
39 | cy.findByText('Read more...').click();
40 |
41 | cy.wait(['@getArticle', '@getCommentsForArticle']);
42 |
43 | cy.location('pathname').should('match', /\/article\/[\w-]+/);
44 | });
45 | });
46 |
47 | it('should navigate to the next page', () => {
48 | cy.wait('@getAllArticles')
49 | .its('response.body')
50 | .then((body) => {
51 | const pages = Math.floor(body.articlesCount / body.articles.length);
52 |
53 | for (let i = 1; i < 3; i++) {
54 | const page = Math.round(Math.random() * (pages - 2 + 1) + 2);
55 |
56 | cy.get('.pagination').findByText(page.toString()).click();
57 |
58 | cy.wait('@getAllArticles');
59 |
60 | cy.get('.pagination')
61 | .findByText(page.toString())
62 | .parent()
63 | .should('have.class', 'active');
64 |
65 | cy.get('.page-item:not(.active)')
66 | .its('length')
67 | .should('equal', pages - 1);
68 | }
69 | });
70 | });
71 |
72 | it('should navigate by tag', () => {
73 | cy.wait('@getAllTags')
74 | .its('response.body')
75 | .then(({ tags }) => {
76 | let tag = tags[Math.floor(Math.random() * tags.length)];
77 |
78 | cy.get('.sidebar').findByText(tag).click();
79 |
80 | cy.wait('@getAllArticles');
81 |
82 | cy.get('.feed-toggle').findByText(tag).should('have.class', 'active');
83 |
84 | cy.get('.pagination').findByText('2').click();
85 |
86 | cy.wait('@getAllArticles');
87 |
88 | tag = tags[Math.floor(Math.random() * tags.length)];
89 |
90 | cy.get('.sidebar').findByText(tag).click();
91 |
92 | cy.wait('@getAllArticles');
93 | });
94 | });
95 | });
96 |
97 | describe('Navigation (authenticated)', () => {
98 | beforeEach(() => {
99 | cy.intercept('**/articles?*')
100 | .as('getAllArticles')
101 | .intercept('**/tags')
102 | .as('getAllTags')
103 | .intercept(`**/profiles/*`)
104 | .as('getProfile')
105 | .visit('/')
106 | .login();
107 | });
108 |
109 | it('should switch between tabs', () => {
110 | cy.findByRole('button', {
111 | name: /global feed/i,
112 | }).click();
113 |
114 | cy.wait('@getAllArticles').its('response.statusCode').should('equal', 200);
115 |
116 | cy.findByRole('button', {
117 | name: /your feed/i,
118 | }).click();
119 | });
120 |
121 | it('should navigate to new post page', () => {
122 | cy.wait('@getAllTags');
123 |
124 | cy.findByRole('link', {
125 | name: /new post/i,
126 | }).click();
127 |
128 | cy.location('pathname').should('equal', '/editor');
129 | });
130 |
131 | it('should navigate to settings page', () => {
132 | cy.findByRole('link', {
133 | name: /settings/i,
134 | }).click();
135 |
136 | cy.location('pathname').should('equal', '/settings');
137 | });
138 |
139 | it('should navigate to my profile page', () => {
140 | cy.findByRole('navigation')
141 | .findByRole('link', {
142 | name: RegExp(Cypress.env('username')),
143 | })
144 | .click();
145 |
146 | cy.location('pathname').should('equal', `/@${Cypress.env('username')}`);
147 |
148 | cy.get('.user-info').within(() => {
149 | cy.findByRole('img', Cypress.env('username')).should('be.visible');
150 |
151 | cy.findByRole('heading', Cypress.env('username')).should('be.visible');
152 | });
153 | });
154 |
155 | it('should navigate to my favorited articles page', () => {
156 | cy.findByRole('navigation')
157 | .findByRole('link', {
158 | name: RegExp(Cypress.env('username')),
159 | })
160 | .click();
161 |
162 | cy.wait(['@getAllArticles', '@getProfile']);
163 |
164 | cy.get('.user-info').within(() => {
165 | cy.findByRole('img', Cypress.env('username')).should('be.visible');
166 |
167 | cy.findByRole('heading', Cypress.env('username')).should('be.visible');
168 | });
169 |
170 | cy.findByRole('link', {
171 | name: /favorited articles/i,
172 | }).click();
173 |
174 | cy.wait('@getAllArticles')
175 | .its('response.body')
176 | .then((body) => {
177 | const pages = Math.floor(body.articlesCount / body.articles.length);
178 | const page = Math.round(Math.random() * (pages - 2 + 1) + 2);
179 |
180 | cy.get('.pagination').findByText(page.toString()).click();
181 |
182 | cy.wait('@getAllArticles');
183 | });
184 |
185 | cy.location('pathname').should(
186 | 'equal',
187 | `/@${Cypress.env('username')}/favorites`
188 | );
189 | });
190 | });
191 |
--------------------------------------------------------------------------------
/cypress/integration/profile-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | describe('Profile page', () => {
8 | const firstname = faker.name.firstName();
9 | const lastname = faker.name.lastName();
10 | const username =
11 | faker.internet
12 | .userName(firstname, lastname)
13 | .toLowerCase()
14 | .replace(/\W/g, '_') + '_redux';
15 | const email = faker.internet
16 | .email(firstname, lastname, 'redux.js.org')
17 | .toLowerCase();
18 |
19 | before(() => {
20 | cy.task('createUserWithArticle', { username, email });
21 |
22 | cy.intercept('GET', '**/user')
23 | .as('getCurrentUser')
24 | .intercept('GET', `**/profiles/${username}`)
25 | .as('getProfile')
26 | .intercept('GET', `**/articles?author=${encodeURIComponent(username)}*`)
27 | .as('getArticlesByAuthor')
28 | .visit('/')
29 | .login()
30 | .visit(`/@${username}`)
31 | .wait(['@getCurrentUser', '@getProfile', '@getArticlesByAuthor']);
32 | });
33 |
34 | afterEach(function () {
35 | if (this.currentTest.state === 'failed') {
36 | Cypress.runner.stop();
37 | }
38 | });
39 |
40 | it("should show the user's image and user's username in the banner", () => {
41 | cy.get('.user-info').within(() => {
42 | cy.findByRole('img', username).should('be.visible');
43 |
44 | cy.findByRole('heading', username).should('be.visible');
45 | });
46 |
47 | cy.get('.article-preview').its('length').should('be.above', 0);
48 | });
49 |
50 | it('should follow the user', () => {
51 | cy.intercept('POST', `**/profiles/${username}/follow`).as('followProfile');
52 |
53 | cy.findByRole('button', { name: `Follow ${username}` }).click();
54 |
55 | cy.wait('@followProfile').its('response.statusCode').should('equal', 200);
56 | });
57 |
58 | it('should unfollow the user', () => {
59 | cy.intercept('DELETE', `**/profiles/${username}/follow`).as(
60 | 'unfollowProfile'
61 | );
62 |
63 | cy.findByRole('button', { name: `Unfollow ${username}` }).click();
64 |
65 | cy.wait('@unfollowProfile').its('response.statusCode').should('equal', 200);
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/cypress/integration/register-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | describe('Register page', () => {
8 | const usernamePlaceholder = 'Username';
9 | const emailPlaceholder = 'Email';
10 | const passwordPlaceholder = 'Password';
11 |
12 | beforeEach(() => {
13 | cy.intercept('POST', '**/users').as('register').visit('/register');
14 | });
15 |
16 | it('should submit the register form', () => {
17 | cy.findByPlaceholderText(usernamePlaceholder).type(
18 | faker.internet.userName().toLowerCase().replace(/\W/g, '_').substr(0, 20)
19 | );
20 |
21 | cy.findByPlaceholderText(emailPlaceholder).type(
22 | faker.internet.exampleEmail().toLowerCase()
23 | );
24 |
25 | cy.findByPlaceholderText(passwordPlaceholder).type('Pa$$w0rd!');
26 |
27 | cy.findByRole('button', { name: /sign up/i }).click();
28 |
29 | cy.wait('@register').its('response.statusCode').should('equal', 200);
30 |
31 | cy.location('pathname').should('be.equal', '/');
32 | });
33 |
34 | it('should require all the fields', () => {
35 | cy.findByRole('button', { name: /sign up/i }).click();
36 |
37 | cy.wait('@register').its('response.statusCode').should('equal', 422);
38 |
39 | cy.get('.error-messages').within(() => {
40 | cy.findAllByRole('listitem').should('have.length', 3);
41 | });
42 | });
43 |
44 | it('should require the username', () => {
45 | cy.findByPlaceholderText(emailPlaceholder).type(
46 | faker.internet.exampleEmail().toLowerCase()
47 | );
48 |
49 | cy.findByPlaceholderText(passwordPlaceholder).type(
50 | faker.internet.password()
51 | );
52 |
53 | cy.findByRole('button', { name: /sign up/i }).click();
54 |
55 | cy.wait('@register').its('response.statusCode').should('equal', 422);
56 |
57 | cy.get('.error-messages').within(() => {
58 | cy.findByRole('listitem').should('contain.text', 'username');
59 | });
60 | });
61 |
62 | it('should require the email', () => {
63 | cy.findByPlaceholderText(usernamePlaceholder).type(
64 | faker.internet.userName().toLowerCase().replace(/\W/g, '_').substr(0, 20)
65 | );
66 |
67 | cy.findByPlaceholderText(passwordPlaceholder).type(
68 | faker.internet.password()
69 | );
70 |
71 | cy.findByRole('button', { name: /sign up/i }).click();
72 |
73 | cy.wait('@register').its('response.statusCode').should('equal', 422);
74 |
75 | cy.get('.error-messages').within(() => {
76 | cy.findByRole('listitem').should('contain.text', 'email');
77 | });
78 | });
79 |
80 | it('should require the password', () => {
81 | cy.findByPlaceholderText(usernamePlaceholder).type(
82 | faker.internet.userName().toLowerCase().replace(/\W/g, '_').substr(0, 20)
83 | );
84 |
85 | cy.findByPlaceholderText(emailPlaceholder).type(
86 | faker.internet.email().toLowerCase()
87 | );
88 |
89 | cy.findByRole('button', { name: /sign up/i }).click();
90 |
91 | cy.wait('@register').its('response.statusCode').should('equal', 422);
92 |
93 | cy.get('.error-messages').within(() => {
94 | cy.findByRole('listitem').should('contain.text', 'password');
95 | });
96 | });
97 |
98 | it('should navigate to login page', () => {
99 | cy.findByRole('link', { name: /have an account/i }).click();
100 |
101 | cy.location('pathname').should('be.equal', '/login');
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/cypress/integration/settings-spec.js:
--------------------------------------------------------------------------------
1 | // enables intelligent code completion for Cypress commands
2 | // https://on.cypress.io/intelligent-code-completion
3 | ///
4 | ///
5 | import faker from 'faker';
6 |
7 | describe('Settings page', () => {
8 | const imagePlaceholder = 'URL of profile picture';
9 | const bioPlaceholder = 'Short bio about you';
10 | const passwordPlaceholder = 'New Password';
11 | const submitButton = 'Update Settings';
12 |
13 | beforeEach(() => {
14 | cy.visit('/').login();
15 |
16 | cy.findByRole('link', {
17 | name: /settings/i,
18 | }).click();
19 |
20 | cy.intercept('PUT', '**/user').as('saveUser');
21 | });
22 |
23 | it("should update user's details", () => {
24 | cy.findByPlaceholderText(imagePlaceholder)
25 | .clear()
26 | .type(faker.image.avatar());
27 |
28 | cy.findByPlaceholderText(bioPlaceholder)
29 | .clear()
30 | .type(faker.hacker.phrase());
31 |
32 | cy.findByRole('button', { name: submitButton }).click();
33 |
34 | cy.wait('@saveUser').its('response.statusCode').should('equal', 200);
35 |
36 | cy.location('pathname').should('equal', '/');
37 | });
38 |
39 | it('should change the password', () => {
40 | cy.findByPlaceholderText(passwordPlaceholder).type(Cypress.env('password'));
41 |
42 | cy.findByRole('button', { name: submitButton }).click();
43 |
44 | cy.wait('@saveUser').its('response.statusCode').should('equal', 200);
45 |
46 | cy.location('pathname').should('equal', '/');
47 | });
48 |
49 | it('should logout the user', () => {
50 | cy.findByRole('button', { name: 'Or click here to logout.' }).click();
51 |
52 | cy.location('pathname').should('equal', '/');
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // ***********************************************************
4 | // This example plugins/index.js can be used to load plugins
5 | //
6 | // You can change the location of this file or turn off loading
7 | // the plugins file with the 'pluginsFile' configuration option.
8 | //
9 | // You can read more here:
10 | // https://on.cypress.io/plugins-guide
11 | // ***********************************************************
12 |
13 | // This function is called when a project is opened or re-opened (e.g. due to
14 | // the project's config changing)
15 | const faker = require('faker');
16 | const fetch = require('node-fetch');
17 |
18 | /**
19 | * @type {Cypress.PluginConfig}
20 | */
21 | module.exports = (on, config) => {
22 | // `on` is used to hook into various events Cypress emits
23 | // `config` is the resolved Cypress config
24 | require('@cypress/code-coverage/task')(on, config);
25 |
26 | on('task', {
27 | async createUserWithArticle({
28 | username = faker.internet.userName(),
29 | email = faker.internet.exampleEmail(),
30 | password = 'Pa$$w0rd!',
31 | followUser = false,
32 | }) {
33 | let user = { username: username.substr(-20), email, password };
34 | let article = {
35 | title: faker.lorem.words(),
36 | description: faker.lorem.sentences(),
37 | body: faker.fake(`
38 |
39 | > {{lorem.sentence}}
40 |
41 | -----
42 |
43 | ## {{lorem.text}}
44 |
45 | {{lorem.paragraph}}
46 |
47 | - _{{lorem.word}}_
48 | - _{{lorem.word}}_
49 | - _{{lorem.word}}_
50 |
51 | ### {{lorem.text}}
52 |
53 | {{lorem.paragraph}}
54 |
55 | 1. **{{lorem.words}}**
56 | 2. **{{lorem.words}}**
57 | 3. **{{lorem.words}}**
58 |
59 | {{lorem.paragraph}}
60 |
61 | * [x] ~{{lorem.words}}~
62 | * [x] ~{{lorem.words}}~
63 | * [ ] {{lorem.words}}
64 |
65 | > {{hacker.phrase}}
66 |
67 | \`\`\`
68 |
71 | \`\`\`
72 | `),
73 | tagList: ['lorem ipsum', 'markdown'].concat(
74 | ...faker.lorem.words(5).split(' ')
75 | ),
76 | };
77 |
78 | // Create a new user
79 | user = await fetch(`${config.env.apiUrl}/users`, {
80 | method: 'POST',
81 | body: JSON.stringify({ user }),
82 | headers: {
83 | 'content-type': 'application/json',
84 | },
85 | })
86 | .then((response) => response.json())
87 | .then((body) => body.user);
88 | // Update the user
89 | user = await fetch(`${config.env.apiUrl}/user`, {
90 | method: 'PUT',
91 | body: JSON.stringify({
92 | user: {
93 | bio: faker.hacker.phrase(),
94 | image: faker.image.avatar(),
95 | },
96 | }),
97 | headers: {
98 | 'content-type': 'application/json',
99 | authorization: `Token ${user.token}`,
100 | },
101 | })
102 | .then((response) => response.json())
103 | .then((body) => body.user);
104 | // Create an article
105 | article = await fetch(`${config.env.apiUrl}/articles`, {
106 | method: 'POST',
107 | body: JSON.stringify({ article }),
108 | headers: {
109 | 'content-type': 'application/json',
110 | authorization: `Token ${user.token}`,
111 | },
112 | })
113 | .then((response) => response.json())
114 | .then((body) => body.article);
115 |
116 | const comment = await fetch(
117 | `${config.env.apiUrl}/articles/${article.slug}/comments`,
118 | {
119 | method: 'POST',
120 | body: JSON.stringify({
121 | comment: {
122 | body: faker.lorem.text(),
123 | },
124 | }),
125 | headers: {
126 | 'content-type': 'application/json',
127 | authorization: `Token ${user.token}`,
128 | },
129 | }
130 | )
131 | .then((response) => response.json())
132 | .then((body) => body.comment);
133 |
134 | if (followUser) {
135 | const { token } = await fetch(`${config.env.apiUrl}/users/login`, {
136 | method: 'POST',
137 | body: JSON.stringify({
138 | user: {
139 | email: config.env.email,
140 | password: config.env.password,
141 | },
142 | }),
143 | headers: {
144 | 'content-type': 'application/json',
145 | },
146 | })
147 | .then((response) => response.json())
148 | .then((body) => body.user);
149 |
150 | await fetch(`${config.env.apiUrl}/profiles/${username}/follow`, {
151 | method: 'POST',
152 | headers: {
153 | authorization: `Token ${token}`,
154 | },
155 | });
156 | }
157 |
158 | return {
159 | ...article,
160 | author: {
161 | ...article.author,
162 | ...user,
163 | token: undefined,
164 | },
165 | comments: [comment],
166 | };
167 | },
168 | });
169 | // IMPORTANT to return the config object
170 | // with the any changed environment variables
171 | return config;
172 | };
173 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example support/index.js is processed and
4 | // loaded automatically before your test files.
5 | //
6 | // This is a great place to put global configuration and
7 | // behavior that modifies Cypress.
8 | //
9 | // You can change the location of this file or turn off
10 | // automatically serving support files with the
11 | // 'supportFile' configuration option.
12 | //
13 | // You can read more here:
14 | // https://on.cypress.io/configuration
15 | // ***********************************************************
16 | import '@cypress/code-coverage/support';
17 | import '@testing-library/cypress/add-commands';
18 | import faker from 'faker';
19 |
20 | import { login } from '../../src/reducers/auth';
21 |
22 | /**
23 | * Dispatches a given Redux action straight to the application
24 | */
25 | Cypress.Commands.add('dispatch', (action) => {
26 | expect(action).to.be.an('object').and.to.have.property('type');
27 |
28 | cy.window().its('store').invoke('dispatch', action);
29 | });
30 |
31 | /**
32 | * Login the user using the API, then dispatch the login action
33 | */
34 | Cypress.Commands.add(
35 | 'login',
36 | (email = Cypress.env('email'), password = Cypress.env('password')) => {
37 | cy.request({
38 | url: `${Cypress.env('apiUrl')}/users/login`,
39 | method: 'POST',
40 | body: { user: { email, password } },
41 | })
42 | .its('body')
43 | .then((body) => {
44 | cy.dispatch({ type: login.fulfilled.type, payload: body });
45 | });
46 | }
47 | );
48 |
49 | /**
50 | * Create a new article (with a markdown body) using the API
51 | *
52 | * @returns {Object} article
53 | */
54 | Cypress.Commands.add('createArticle', () =>
55 | cy
56 | .request({
57 | method: 'POST',
58 | url: `${Cypress.env('apiUrl')}/articles`,
59 | body: {
60 | article: {
61 | title: faker.company.catchPhrase(),
62 | description: faker.commerce.productDescription(),
63 | body: faker.lorem.paragraph(),
64 | tagList: ['lorem ipsum', 'markdown', 'faker'].concat(
65 | ...faker.lorem.words(5).split(' ')
66 | ),
67 | },
68 | },
69 | headers: {
70 | authorization: `Token ${localStorage.getItem('jwt')}`,
71 | },
72 | })
73 | .its('body.article')
74 | );
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-realworld-example-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "@cypress/code-coverage": "^3.10.4",
7 | "@cypress/instrument-cra": "^1.4.0",
8 | "@testing-library/cypress": "^9.0.0",
9 | "@testing-library/jest-dom": "^5.16.5",
10 | "@testing-library/react": "^14.0.0",
11 | "@testing-library/user-event": "^14.4.3",
12 | "cross-env": "^7.0.3",
13 | "cypress": "^12.12.0",
14 | "faker": "^6.6.6",
15 | "husky": "^8.0.3",
16 | "jest-fetch-mock": "^3.0.3",
17 | "node-fetch": "^3.3.1",
18 | "prettier": "^2.8.8",
19 | "pretty-quick": "^3.1.3",
20 | "react-scripts": "^5.0.1",
21 | "redux-devtools-extension": "^2.13.9",
22 | "redux-testkit": "^1.0.6",
23 | "start-server-and-test": "^2.0.0"
24 | },
25 | "dependencies": {
26 | "@reduxjs/toolkit": "^1.9.5",
27 | "history": "^5.3.0",
28 | "marked": "^5.0.2",
29 | "prop-types": "^15.8.1",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "react-redux": "^8.0.5",
33 | "react-router": "^6.11.1",
34 | "react-router-dom": "^6.11.1",
35 | "redux": "^4.2.1",
36 | "redux-logger": "^3.0.6",
37 | "snarkdown": "^2.0.0",
38 | "superagent": "^8.0.9",
39 | "superagent-promise": "^1.1.0",
40 | "xss": "^1.0.14"
41 | },
42 | "scripts": {
43 | "start": "cross-env PORT=4100 react-scripts start",
44 | "start:e2e": "cross-env PORT=4100 BROWSER=none react-scripts -r @cypress/instrument-cra start",
45 | "build": "react-scripts build",
46 | "test": "cross-env PORT=4100 react-scripts test --env=jsdom",
47 | "eject": "react-scripts eject",
48 | "cy:open": "cypress open",
49 | "cy:run": "cypress run",
50 | "test:e2e": "start-test start:e2e :4100 cy:run",
51 | "prepare": "husky install"
52 | },
53 | "browserslist": [
54 | ">0.2%",
55 | "not dead",
56 | "not ie <= 11",
57 | "not op_mini all"
58 | ]
59 | }
60 |
--------------------------------------------------------------------------------
/project-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khaledosman/react-redux-realworld-example-app/53b0b4c0b8c371053a8d082ff9a42bfae68f3755/project-logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khaledosman/react-redux-realworld-example-app/53b0b4c0b8c371053a8d082ff9a42bfae68f3755/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
19 | Conduit
20 |
21 |
22 |
23 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/agent.js:
--------------------------------------------------------------------------------
1 | const API_ROOT =
2 | process.env.REACT_APP_BACKEND_URL ?? 'https://conduit.productionready.io/api';
3 |
4 | /**
5 | * Serialize object to URL params
6 | *
7 | * @param {Record} object
8 | * @returns {String}
9 | */
10 | function serialize(object) {
11 | const params = [];
12 |
13 | for (const param in object) {
14 | if (Object.hasOwnProperty.call(object, param) && object[param] != null) {
15 | params.push(`${param}=${encodeURIComponent(object[param])}`);
16 | }
17 | }
18 |
19 | return params.join('&');
20 | }
21 |
22 | let token = null;
23 |
24 | /**
25 | *
26 | * @typedef {Object} ApiError
27 | * @property {{[property: string]: string}} errors
28 | */
29 |
30 | /**
31 | * @typedef {Object} UserAuth
32 | * @property {Object} user
33 | * @property {String} user.email
34 | * @property {String} user.username
35 | * @property {String} user.bio
36 | * @property {String} user.image
37 | * @property {String} user.token
38 | *
39 | * @typedef {Object} Profile
40 | * @property {String} username
41 | * @property {String} bio
42 | * @property {String} image
43 | * @property {Boolean} following
44 | *
45 | * @typedef {Object} Tags
46 | * @property {String[]} tags
47 | *
48 | * @typedef {Object} Article
49 | * @property {String} title
50 | * @property {String} slug
51 | * @property {String} body
52 | * @property {String} description
53 | * @property {String[]} tagList
54 | * @property {Profile} author
55 | * @property {Boolean} favorited
56 | * @property {Number} favoritesCount
57 | * @property {String} createdAt
58 | * @property {String} updatedAt
59 | *
60 | * @typedef {Object} ArticleResponse
61 | * @property {Article} article
62 | *
63 | * @typedef {Object} ArticlesResponse
64 | * @property {Article[]} articles
65 | * @property {Number} articlesCount
66 | *
67 | * @typedef {Object} Comment
68 | * @property {String} id
69 | * @property {String} body
70 | * @property {Profile} author
71 | * @property {String} createdAt
72 | * @property {String} updatedAt
73 | *
74 | * @typedef {Object} CommentResponse
75 | * @property {Comment} comment
76 | *
77 | * @typedef {Object} CommentsResponse
78 | * @property {Comment[]} comments
79 | *
80 | * @typedef {Object} ProfileResponse
81 | * @property {Profile} profile
82 | */
83 |
84 | /**
85 | * API client
86 | *
87 | * @param {String} url The endpoint
88 | * @param {Object} body The request's body
89 | * @param {('GET'|'DELETE'|'PUT'|'POST')} [method='GET'] The request's method
90 | *
91 | * @throws {@link ApiError API Error}
92 | *
93 | * @returns {Promise