├── .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 | # ![React + Redux Example App](project-logo.png) 2 | [![Netlify Status](https://api.netlify.com/api/v1/badges/aa569c8f-ebd5-413e-9fb2-e34facc71873/deploy-status)](https://app.netlify.com/sites/react-redux-realworld/deploys) 3 | [![RealWorld Frontend](https://img.shields.io/badge/realworld-frontend-%23783578.svg)](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 | [![Brought to you by Thinkster](https://raw.githubusercontent.com/gothinkster/realworld/master/media/end.png)](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(`![{{lorem.words}}]({{image.city}}) 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} API response's body 94 | */ 95 | const agent = async (url, body, method = 'GET') => { 96 | const headers = new Headers(); 97 | 98 | if (body) { 99 | headers.set('Content-Type', 'application/json'); 100 | } 101 | 102 | if (token) { 103 | headers.set('Authorization', `Token ${token}`); 104 | } 105 | 106 | const response = await fetch(`${API_ROOT}${url}`, { 107 | method, 108 | headers, 109 | body: body ? JSON.stringify(body) : undefined, 110 | }); 111 | let result; 112 | 113 | try { 114 | result = await response.json(); 115 | } catch (error) { 116 | result = { errors: { [response.status]: [response.statusText] } }; 117 | } 118 | 119 | if (!response.ok) throw result; 120 | 121 | return result; 122 | }; 123 | 124 | const requests = { 125 | /** 126 | * Send a DELETE request 127 | * 128 | * @param {String} url The endpoint 129 | * @returns {Promise} 130 | */ 131 | del: (url) => agent(url, undefined, 'DELETE'), 132 | /** 133 | * Send a GET request 134 | * 135 | * @param {String} url The endpoint 136 | * @param {Object} [query={}] URL parameters 137 | * @param {Number} [query.limit=10] 138 | * @param {Number} [query.page] 139 | * @param {String} [query.author] 140 | * @param {String} [query.tag] 141 | * @param {String} [query.favorited] 142 | * @returns {Promise} 143 | */ 144 | get: (url, query = {}) => { 145 | if (Number.isSafeInteger(query?.page)) { 146 | query.limit = query.limit ? query.limit : 10; 147 | query.offset = query.page * query.limit; 148 | } 149 | delete query.page; 150 | const isEmptyQuery = query == null || Object.keys(query).length === 0; 151 | 152 | return agent(isEmptyQuery ? url : `${url}?${serialize(query)}`); 153 | }, 154 | /** 155 | * Send a PUT request 156 | * 157 | * @param {String} url The endpoint 158 | * @param {Record} body The request's body 159 | * @returns {Promise} 160 | */ 161 | put: (url, body) => agent(url, body, 'PUT'), 162 | /** 163 | * Send a POST request 164 | * 165 | * @param {String} url The endpoint 166 | * @param {Record} body The request's body 167 | * @returns {Promise} 168 | */ 169 | post: (url, body) => agent(url, body, 'POST'), 170 | }; 171 | 172 | const Auth = { 173 | /** 174 | * Get current user 175 | * 176 | * @returns {Promise} 177 | */ 178 | current: () => requests.get('/user'), 179 | /** 180 | * Login with email and password 181 | * 182 | * @param {String} email 183 | * @param {String} password 184 | * @returns {Promise} 185 | */ 186 | login: (email, password) => 187 | requests.post('/users/login', { user: { email, password } }), 188 | /** 189 | * Register with username, email and password 190 | * 191 | * @param {String} username 192 | * @param {String} email 193 | * @param {String} password 194 | * @returns {Promise} 195 | */ 196 | register: (username, email, password) => 197 | requests.post('/users', { user: { username, email, password } }), 198 | /** 199 | * Update user 200 | * 201 | * @param {Object} user 202 | * @param {String} [user.email] 203 | * @param {String} [user.username] 204 | * @param {String} [user.bio] 205 | * @param {String} [user.image] 206 | * @param {String} [user.password] 207 | * @returns {Promise} 208 | */ 209 | save: (user) => requests.put('/user', { user }), 210 | }; 211 | 212 | const Tags = { 213 | /** 214 | * Get all tags 215 | * 216 | * @returns {Promise} 217 | */ 218 | getAll: () => requests.get('/tags'), 219 | }; 220 | 221 | const Articles = { 222 | /** 223 | * Get all articles 224 | * 225 | * @param {Object} query Article's query parameters 226 | * @param {Number} [query.limit=10] 227 | * @param {Number} [query.page] 228 | * @param {String} [query.author] 229 | * @param {String} [query.tag] 230 | * @param {String} [query.favorited] 231 | * @returns {Promise} 232 | */ 233 | all: (query) => requests.get(`/articles`, query), 234 | /** 235 | * Get all articles from author 236 | * 237 | * @param {String} author Article's author 238 | * @param {Number} [page] 239 | * @returns {Promise} 240 | */ 241 | byAuthor: (author, page) => 242 | requests.get(`/articles`, { author, limit: 5, page }), 243 | /** 244 | * Get all articles by tag 245 | * 246 | * @param {String} tag Article's tag 247 | * @param {Number} page 248 | * @returns {Promise} 249 | */ 250 | byTag: (tag, page) => requests.get(`/articles`, { tag, page }), 251 | /** 252 | * Remove one article 253 | * 254 | * @param {String} slug Article's slug 255 | * @returns {Promise<{}>} 256 | */ 257 | del: (slug) => requests.del(`/articles/${slug}`), 258 | /** 259 | * Favorite one article 260 | * 261 | * @param {String} slug Article's slug 262 | * @returns {Promise} 263 | */ 264 | favorite: (slug) => requests.post(`/articles/${slug}/favorite`), 265 | /** 266 | * Get article favorited by author 267 | * 268 | * @param {String} username Username 269 | * @param {Number} [page] 270 | * @returns {Promise} 271 | */ 272 | favoritedBy: (username, page) => 273 | requests.get(`/articles`, { favorited: username, limit: 5, page }), 274 | /** 275 | * Get all articles in the user's feed 276 | * 277 | * @param {Number} [page] 278 | * @returns {Promise} 279 | */ 280 | feed: (page) => requests.get('/articles/feed', { page }), 281 | /** 282 | * Get one article by slug 283 | * 284 | * @param {String} slug Article's slug 285 | * @returns {Promise} 286 | */ 287 | get: (slug) => requests.get(`/articles/${slug}`), 288 | /** 289 | * Unfavorite one article 290 | * 291 | * @param {String} slug Article's slug 292 | * @returns {Promise} 293 | */ 294 | unfavorite: (slug) => requests.del(`/articles/${slug}/favorite`), 295 | /** 296 | * Update one article 297 | * 298 | * @param {Partial
} article 299 | * @returns {Promise} 300 | */ 301 | update: ({ slug, ...article }) => 302 | requests.put(`/articles/${slug}`, { article }), 303 | /** 304 | * Create a new article 305 | * 306 | * @param {Object} article 307 | * @param {String} article.title 308 | * @param {String} article.description 309 | * @param {String} article.body 310 | * @param {String[]} article.tagList 311 | * @returns {Promise} 312 | */ 313 | create: (article) => requests.post('/articles', { article }), 314 | }; 315 | 316 | const Comments = { 317 | /** 318 | * Create a new comment for article 319 | * 320 | * @param {String} slug Article's slug 321 | * @param {Object} comment 322 | * @param {String} comment.body 323 | * @returns {Promise} 324 | */ 325 | create: (slug, comment) => 326 | requests.post(`/articles/${slug}/comments`, { comment }), 327 | /** 328 | * Remove one comment 329 | * 330 | * @param {String} slug Article's slug 331 | * @param {String} commentId Comment's id 332 | * @returns {Promise<{}>} 333 | */ 334 | delete: (slug, commentId) => 335 | requests.del(`/articles/${slug}/comments/${commentId}`), 336 | /** 337 | * Get all comments for one article 338 | * 339 | * @param {String} slug Article's slug 340 | * @returns {Promise} 341 | */ 342 | forArticle: (slug) => requests.get(`/articles/${slug}/comments`), 343 | }; 344 | 345 | const Profile = { 346 | /** 347 | * Follow another user 348 | * 349 | * @param {String} username User's username 350 | * @returns {Profile} 351 | */ 352 | follow: (username) => requests.post(`/profiles/${username}/follow`), 353 | /** 354 | * Get the profile of an user 355 | * 356 | * @param {String} username User's username 357 | * @returns {Profile} 358 | */ 359 | get: (username) => requests.get(`/profiles/${username}`), 360 | /** 361 | * Unfollow another user 362 | * 363 | * @param {String} username User's username 364 | * @returns {Profile} 365 | */ 366 | unfollow: (username) => requests.del(`/profiles/${username}/follow`), 367 | }; 368 | 369 | export default { 370 | Articles, 371 | Auth, 372 | Comments, 373 | Profile, 374 | Tags, 375 | setToken: (_token) => { 376 | token = _token; 377 | }, 378 | }; 379 | -------------------------------------------------------------------------------- /src/app/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export default createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /src/app/middleware.js: -------------------------------------------------------------------------------- 1 | import agent from '../agent'; 2 | import { login, logout, register } from '../features/auth/authSlice'; 3 | 4 | const localStorageMiddleware = (store) => (next) => (action) => { 5 | switch (action.type) { 6 | case register.fulfilled.type: 7 | case login.fulfilled.type: 8 | window.localStorage.setItem('jwt', action.payload.token); 9 | agent.setToken(action.payload.token); 10 | break; 11 | 12 | case logout.type: 13 | window.localStorage.removeItem('jwt'); 14 | agent.setToken(undefined); 15 | break; 16 | } 17 | 18 | return next(action); 19 | }; 20 | 21 | export { localStorageMiddleware }; 22 | -------------------------------------------------------------------------------- /src/app/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | // import { connectRouter, routerMiddleware } from 'connected-react-router'; 3 | 4 | import authReducer from '../features/auth/authSlice'; 5 | import commentsReducer from '../features/comments/commentsSlice'; 6 | import tagsReducer from '../features/tags/tagsSlice'; 7 | import history from './history'; 8 | import { localStorageMiddleware } from './middleware'; 9 | import articleReducer from '../reducers/article'; 10 | import articlesReducer from '../reducers/articleList'; 11 | import commonReducer from '../reducers/common'; 12 | import profileReducer from '../reducers/profile'; 13 | 14 | export function makeStore(preloadedState) { 15 | return configureStore({ 16 | reducer: { 17 | article: articleReducer, 18 | articleList: articlesReducer, 19 | auth: authReducer, 20 | comments: commentsReducer, 21 | common: commonReducer, 22 | profile: profileReducer, 23 | tags: tagsReducer, 24 | // router: connectRouter(history), 25 | }, 26 | devTools: true, 27 | preloadedState, 28 | middleware: (getDefaultMiddleware) => [ 29 | ...getDefaultMiddleware(), 30 | // routerMiddleware(history), 31 | localStorageMiddleware, 32 | ], 33 | }); 34 | } 35 | 36 | const store = makeStore(); 37 | 38 | export default store; 39 | -------------------------------------------------------------------------------- /src/common/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * States of the slice 3 | * @readonly 4 | * @enum {string} 5 | */ 6 | export const Status = { 7 | /** The initial state */ 8 | IDLE: 'idle', 9 | /** The loading state */ 10 | LOADING: 'loading', 11 | /** The success state */ 12 | SUCCESS: 'success', 13 | /** The error state */ 14 | FAILURE: 'failure', 15 | }; 16 | 17 | /** 18 | * Check if error is an ApiError 19 | * 20 | * @param {object} error 21 | * @returns {boolean} error is ApiError 22 | */ 23 | export function isApiError(error) { 24 | return typeof error === 'object' && error !== null && 'errors' in error; 25 | } 26 | 27 | /** 28 | * Set state as loading 29 | * 30 | * @param {import('@reduxjs/toolkit').Draft} state 31 | */ 32 | export function loadingReducer(state) { 33 | state.status = Status.LOADING; 34 | } 35 | 36 | /** 37 | * @param {import('@reduxjs/toolkit').Draft} state 38 | * @param {import('@reduxjs/toolkit').PayloadAction<{errors: Record} action 39 | */ 40 | export function failureReducer(state, action) { 41 | state.status = Status.FAILURE; 42 | state.errors = action.payload.errors; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense, useEffect, memo } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Route, Routes } from 'react-router-dom'; 4 | 5 | import Home from '../components/Home'; 6 | import { appLoad, clearRedirect } from '../reducers/common'; 7 | import Header from './Header'; 8 | 9 | const Article = lazy(() => 10 | import( 11 | /* webpackChunkName: "Article", webpackPrefetch: true */ '../components/Article' 12 | ) 13 | ); 14 | const Editor = lazy(() => 15 | import( 16 | /* webpackChunkName: "Editor", webpackPrefetch: true */ '../components/Editor' 17 | ) 18 | ); 19 | const AuthScreen = lazy(() => 20 | import( 21 | /* webpackChunkName: "AuthScreen", webpackPrefetch: true */ '../features/auth/AuthScreen' 22 | ) 23 | ); 24 | const Profile = lazy(() => 25 | import( 26 | /* webpackChunkName: "Profile", webpackPrefetch: true */ '../components/Profile' 27 | ) 28 | ); 29 | const SettingsScreen = lazy(() => 30 | import( 31 | /* webpackChunkName: "SettingsScreen", webpackPrefetch: true */ '../features/auth/SettingsScreen' 32 | ) 33 | ); 34 | 35 | function App() { 36 | const dispatch = useDispatch(); 37 | const redirectTo = useSelector((state) => state.common.redirectTo); 38 | const appLoaded = useSelector((state) => state.common.appLoaded); 39 | 40 | useEffect(() => { 41 | if (redirectTo) { 42 | // dispatch(push(redirectTo)); 43 | dispatch(clearRedirect()); 44 | } 45 | }, [redirectTo]); 46 | 47 | useEffect(() => { 48 | const token = window.localStorage.getItem('jwt'); 49 | dispatch(appLoad(token)); 50 | }, []); 51 | 52 | if (appLoaded) { 53 | return ( 54 | <> 55 |
56 | Loading...

}> 57 | 58 | } /> 59 | } /> 60 | } /> 61 | } /> 62 | } /> 63 | } /> 64 | } /> 65 | } 68 | /> 69 | } /> 70 | 71 |
72 | 73 | ); 74 | } 75 | return ( 76 | <> 77 |
78 |

Loading...

79 | 80 | ); 81 | } 82 | 83 | export default memo(App); 84 | -------------------------------------------------------------------------------- /src/components/Article/ArticleActions.js: -------------------------------------------------------------------------------- 1 | import { Link, useParams, useNavigate } from 'react-router-dom'; 2 | import React, { memo } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { deleteArticle } from '../../reducers/common'; 6 | 7 | /** 8 | * Show the actions to edit or delete an article 9 | * 10 | * @example 11 | * 12 | */ 13 | function ArticleActions() { 14 | const { slug } = useParams(); 15 | const dispatch = useDispatch(); 16 | const navigate = useNavigate(); 17 | 18 | /** 19 | * @type {React.MouseEventHandler} 20 | */ 21 | const removeArticle = () => { 22 | dispatch(deleteArticle(slug)); 23 | navigate('/'); 24 | }; 25 | 26 | return ( 27 | 28 | 29 | Edit Article 30 | 31 | 32 | 35 | 36 | ); 37 | } 38 | 39 | export default memo(ArticleActions); 40 | -------------------------------------------------------------------------------- /src/components/Article/ArticleMeta.js: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | import { selectUser } from '../../features/auth/authSlice'; 5 | 6 | import ArticleActions from './ArticleActions'; 7 | 8 | /** 9 | * Show information about the current article 10 | * 11 | * @example 12 | * 13 | */ 14 | function ArticleMeta() { 15 | const currentUser = useSelector(selectUser); 16 | const article = useSelector((state) => state.article.article); 17 | const isAuthor = currentUser?.username === article?.author.username; 18 | 19 | if (!article) return null; 20 | 21 | return ( 22 |
23 | 24 | {article.author.username} 31 | 32 | 33 |
34 | 35 | {article.author.username} 36 | 37 | 38 | 41 |
42 | 43 | {isAuthor ? : null} 44 |
45 | ); 46 | } 47 | 48 | export default memo(ArticleMeta); 49 | -------------------------------------------------------------------------------- /src/components/Article/index.js: -------------------------------------------------------------------------------- 1 | import React, { lazy, memo, Suspense, useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { useParams } from 'react-router'; 4 | import snarkdown from 'snarkdown'; 5 | import xss from 'xss'; 6 | 7 | import TagsList from '../../features/tags/TagsList'; 8 | import { articlePageUnloaded, getArticle } from '../../reducers/article'; 9 | import ArticleMeta from './ArticleMeta'; 10 | 11 | const CommentSection = lazy(() => 12 | import( 13 | /* webpackChunkName: "CommentSection", webpackPrefetch: true */ '../../features/comments/CommentSection' 14 | ) 15 | ); 16 | 17 | /** 18 | * Show one article with its comments 19 | * 20 | * @param {import('react-router-dom').RouteComponentProps<{ slug: string }>} props 21 | * @example 22 | *
23 | */ 24 | function Article({ match }) { 25 | const dispatch = useDispatch(); 26 | const article = useSelector((state) => state.article.article); 27 | const inProgress = useSelector((state) => state.article.inProgress); 28 | const { slug } = useParams(); 29 | const renderMarkdown = () => ({ __html: xss(snarkdown(article.body)) }); 30 | 31 | useEffect(() => { 32 | const fetchArticle = dispatch(getArticle(slug)); 33 | return () => { 34 | fetchArticle.abort(); 35 | }; 36 | }, [match]); 37 | 38 | useEffect(() => () => dispatch(articlePageUnloaded()), []); 39 | 40 | if (!article) { 41 | return ( 42 |
43 |
44 |
45 |
46 | {inProgress &&

Article is loading

} 47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | return ( 55 |
56 |
57 |
58 |

{article.title}

59 | 60 |
61 |
62 | 63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 |
73 | 74 | Loading comments

}> 75 | 76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | export default memo(Article); 83 | -------------------------------------------------------------------------------- /src/components/ArticleList.js: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import ArticlePreview from './ArticlePreview'; 5 | import ListPagination from './ListPagination'; 6 | 7 | /** 8 | * List all articles and show pagination 9 | * 10 | * @example 11 | * 12 | */ 13 | function ArticleList() { 14 | const articles = useSelector((state) => state.articleList.articles); 15 | 16 | if (!articles) { 17 | return
Loading...
; 18 | } 19 | 20 | if (articles.length === 0) { 21 | return
No articles are here... yet.
; 22 | } 23 | 24 | return ( 25 | <> 26 | {articles.map((article) => ( 27 | 28 | ))} 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default memo(ArticleList); 36 | -------------------------------------------------------------------------------- /src/components/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { favoriteArticle, unfavoriteArticle } from '../reducers/articleList'; 6 | import TagsList from '../features/tags/TagsList'; 7 | 8 | const FAVORITED_CLASS = 'btn btn-sm btn-primary'; 9 | const NOT_FAVORITED_CLASS = 'btn btn-sm btn-outline-primary'; 10 | 11 | /** 12 | * Show a preview of an article 13 | * 14 | * @param {Object} props 15 | * @param {Object} props.article 16 | * @example 17 | * 36 | */ 37 | function ArticlePreview({ article }) { 38 | const dispatch = useDispatch(); 39 | const favoriteButtonClass = article.favorited 40 | ? FAVORITED_CLASS 41 | : NOT_FAVORITED_CLASS; 42 | 43 | const handleClick = (event) => { 44 | event.preventDefault(); 45 | 46 | if (article.favorited) { 47 | dispatch(unfavoriteArticle(article.slug)); 48 | } else { 49 | dispatch(favoriteArticle(article.slug)); 50 | } 51 | }; 52 | 53 | return ( 54 |
55 |
56 | 57 | {article.author.username} 64 | 65 | 66 |
67 | 68 | {article.author.username} 69 | 70 | 73 |
74 | 75 |
76 | 79 |
80 |
81 | 82 | 83 |

{article.title}

84 |

{article.description}

85 | Read more... 86 | 87 | 88 |
89 | ); 90 | } 91 | 92 | export default memo(ArticlePreview); 93 | -------------------------------------------------------------------------------- /src/components/Editor.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, memo } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import ListErrors from './ListErrors'; 5 | import { 6 | getArticle, 7 | createArticle, 8 | updateArticle, 9 | articlePageUnloaded, 10 | } from '../reducers/article'; 11 | import { useNavigate, useParams } from 'react-router'; 12 | 13 | /** 14 | * Editor component 15 | * @param {import('react-router-dom').RouteComponentProps<{ slug?: string }>} props 16 | * @example 17 | * 18 | */ 19 | function Editor({ match }) { 20 | const dispatch = useDispatch(); 21 | const { article, errors, inProgress } = useSelector((state) => state.article); 22 | const { slug } = useParams(); 23 | const [title, setTitle] = useState(''); 24 | const [description, setDescription] = useState(''); 25 | const [body, setBody] = useState(''); 26 | const [tagInput, setTagInput] = useState(''); 27 | const [tagList, setTagList] = useState([]); 28 | const navigate = useNavigate(); 29 | /** 30 | * @type {React.ChangeEventHandler} 31 | */ 32 | const changeTitle = (event) => { 33 | setTitle(event.target.value); 34 | }; 35 | 36 | /** 37 | * @type {React.ChangeEventHandler} 38 | */ 39 | const changeDescription = (event) => { 40 | setDescription(event.target.value); 41 | }; 42 | 43 | /** 44 | * @type {React.ChangeEventHandler} 45 | */ 46 | const changeBody = (event) => { 47 | setBody(event.target.value); 48 | }; 49 | 50 | /** 51 | * @type {React.ChangeEventHandler} 52 | */ 53 | const changeTagInput = (event) => { 54 | setTagInput(event.target.value); 55 | }; 56 | 57 | /** 58 | * Reset the form values 59 | */ 60 | const reset = () => { 61 | if (slug && article) { 62 | setTitle(article.title); 63 | setDescription(article.description); 64 | setBody(article.body); 65 | setTagList(article.tagList); 66 | } else { 67 | setTitle(''); 68 | setDescription(''); 69 | setBody(''); 70 | setTagInput(''); 71 | setTagList([]); 72 | } 73 | }; 74 | 75 | /** 76 | * Add a tag to tagList 77 | * @type {React.KeyboardEventHandler} 78 | */ 79 | const addTag = (event) => { 80 | if (event.key === 'Enter') { 81 | event.preventDefault(); 82 | 83 | if (tagInput && !tagList.includes(tagInput)) 84 | setTagList([...tagList, tagInput]); 85 | 86 | setTagInput(''); 87 | } 88 | }; 89 | 90 | /** 91 | * Remove a tag from tagList 92 | * 93 | * @param {String} tag 94 | * @returns {React.MouseEventHandler} 95 | */ 96 | const removeTag = (tag) => () => { 97 | setTagList(tagList.filter((_tag) => _tag !== tag)); 98 | }; 99 | 100 | /** 101 | * @type {React.MouseEventHandler} 102 | */ 103 | const submitForm = (event) => { 104 | event.preventDefault(); 105 | const article = { 106 | slug, 107 | title, 108 | description, 109 | body, 110 | tagList, 111 | }; 112 | 113 | dispatch(slug ? updateArticle(article) : createArticle(article)); 114 | navigate('/'); 115 | }; 116 | 117 | useEffect(() => { 118 | reset(); 119 | if (slug) { 120 | dispatch(getArticle(slug)); 121 | } 122 | }, [slug]); 123 | 124 | useEffect(reset, [article]); 125 | 126 | useEffect(() => () => dispatch(articlePageUnloaded()), []); 127 | 128 | return ( 129 |
130 |
131 |
132 |
133 | 134 | 135 |
136 |
137 |
138 | 145 |
146 | 147 |
148 | 155 |
156 | 157 |
158 |