├── .gitignore ├── .node-version ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── authenticatable.rb │ └── graphql_controller.rb ├── graph │ ├── mutations │ │ ├── auth_mutations.rb │ │ ├── comment_mutations.rb │ │ ├── post_mutations.rb │ │ └── user_mutations.rb │ ├── schema.rb │ ├── types │ │ ├── comment_type.rb │ │ ├── field_error_type.rb │ │ ├── mutation_type.rb │ │ ├── post_type.rb │ │ ├── query_type.rb │ │ └── user_type.rb │ └── utils │ │ ├── auth.rb │ │ ├── concurrent_batch_setup.rb │ │ └── record_loader.rb ├── mailers │ └── application_mailer.rb └── models │ ├── application_record.rb │ ├── comment.rb │ ├── concerns │ └── trackable.rb │ ├── field_error.rb │ ├── post.rb │ └── user.rb ├── bin ├── bundle ├── rails ├── rake ├── setup ├── spring ├── update └── yarn ├── client ├── .env ├── .gitignore ├── config-overrides.js ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── assets │ │ └── stylesheets │ │ │ └── scss │ │ │ ├── _layout.scss │ │ │ ├── _posts.scss │ │ │ ├── _users.scss │ │ │ └── application.scss │ ├── components │ │ ├── NotFound.tsx │ │ ├── ScrollToTop.tsx │ │ ├── UserIsAuthenticated.tsx │ │ ├── flash │ │ │ ├── FlashMessage.tsx │ │ │ ├── flashMessageLocalLink.ts │ │ │ └── withFlashMessage.tsx │ │ └── form │ │ │ ├── RenderField.tsx │ │ │ ├── SubmitField.tsx │ │ │ ├── renderField.d.ts │ │ │ └── validation.ts │ ├── config │ │ ├── apolloClient.ts │ │ ├── links.ts │ │ └── rootUrl.ts │ ├── containers │ │ ├── comments │ │ │ ├── _Comment.tsx │ │ │ └── _NewComment.tsx │ │ ├── layouts │ │ │ ├── App.tsx │ │ │ └── Header.tsx │ │ ├── posts │ │ │ ├── AllPosts.tsx │ │ │ ├── EditPost.tsx │ │ │ ├── NewPost.tsx │ │ │ ├── Post.tsx │ │ │ ├── SearchPosts.tsx │ │ │ ├── _HeadListPosts.tsx │ │ │ ├── _ListPosts.tsx │ │ │ ├── _PostActions.tsx │ │ │ ├── _PostForm.tsx │ │ │ ├── _PostInfos.tsx │ │ │ ├── _PostPreview.tsx │ │ │ └── _SearchForm.tsx │ │ └── users │ │ │ ├── ChangeUserPassword.tsx │ │ │ ├── EditUserProfile.tsx │ │ │ ├── SignInUser.tsx │ │ │ └── SignUpUser.tsx │ ├── graphql │ │ ├── auth │ │ │ ├── revokeTokenMutation.graphql │ │ │ └── signInMutation.graphql │ │ ├── flash │ │ │ ├── createFlashMessageMutation.graphql │ │ │ ├── deleteFlashMessageMutation.graphql │ │ │ └── flashMessageQuery.graphql │ │ ├── fragments │ │ │ ├── commentFragment.graphql │ │ │ ├── postForEditingFragment.graphql │ │ │ ├── postFragment.graphql │ │ │ ├── postPreviewFragment.graphql │ │ │ └── userFragment.graphql │ │ ├── posts │ │ │ ├── createCommentMutation.graphql │ │ │ ├── createPostsMutation.graphql │ │ │ ├── deletePostMutation.graphql │ │ │ ├── postForEditingQuery.graphql │ │ │ ├── postQuery.graphql │ │ │ ├── postsQuery.graphql │ │ │ └── updatePostMutation.graphql │ │ └── users │ │ │ ├── cancelAccountMutation.graphql │ │ │ ├── changeUserPasswordMutation.graphql │ │ │ ├── currentUserQuery.graphql │ │ │ ├── signUpMutation.graphql │ │ │ ├── updateUserMutation.graphql │ │ │ └── userForEditingQuery.graphql │ ├── index.tsx │ ├── queries │ │ ├── currentUserQuery.ts │ │ └── postsQuery.ts │ ├── types.ts │ └── utils │ │ ├── errorsUtils.ts │ │ └── stringUtils.ts ├── tsconfig.json ├── tslint.json ├── typings │ ├── apollo-mutation-state.d.ts │ ├── form.d.ts │ ├── graphql.d.ts │ ├── image.d.ts │ └── lodash.d.ts └── yarn.lock ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── backtrace_silencers.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── graphql_patch.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── optics_agent.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── newrelic.yml ├── puma.rb ├── routes.rb ├── secrets.yml └── spring.rb ├── db ├── migrate │ ├── 20161111124620_create_posts.rb │ ├── 20161111124813_create_comments.rb │ ├── 20161111130057_devise_create_users.rb │ ├── 20161111130300_add_user_reference_to_posts_and_comments.rb │ ├── 20161111130742_add_comments_count_to_posts.rb │ ├── 20161111133933_create_active_admin_comments.rb │ ├── 20171017143939_add_refresh_token_to_users.rb │ ├── 20171019193932_rename_password_from_users.rb │ └── 20171124131242_rename_token_to_users.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── log └── .keep ├── package.json ├── test ├── controllers │ └── .keep ├── fixtures │ ├── .keep │ ├── comments.yml │ ├── files │ │ └── .keep │ ├── posts.yml │ └── users.yml ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── comment_test.rb │ ├── post_test.rb │ └── user_test.rb └── test_helper.rb ├── tmp └── .keep └── vendor └── assets ├── javascripts └── .keep └── stylesheets └── .keep /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore Byebug command history file. 21 | .byebug_history 22 | 23 | .DS_Store 24 | npm-debug.log 25 | node_modules 26 | .vscode 27 | client/npm-debug.log* 28 | public/* 29 | client/data/schema.json 30 | client/build 31 | client/src/assets/stylesheets/css -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 8.9.1 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | TargetRubyVersion: 2.4.1 4 | Exclude: 5 | - 'lib/graphql/language/lexer.rb' 6 | - 'lib/graphql/language/parser.rb' 7 | - 'gemfiles/**/*' 8 | - 'tmp/**/*' 9 | - 'vendor/**/*' 10 | - '**/node_modules/**/*' 11 | 12 | # def ... 13 | # end 14 | Lint/DefEndAlignment: 15 | EnforcedStyleAlignWith: def 16 | 17 | # value = if 18 | # # ... 19 | # end 20 | Lint/EndAlignment: 21 | EnforcedStyleAlignWith: variable 22 | 23 | Lint/UselessAssignment: 24 | Enabled: true 25 | 26 | Metrics/ParameterLists: 27 | Max: 7 28 | CountKeywordArgs: false 29 | 30 | Layout/EmptyLineBetweenDefs: 31 | AllowAdjacentOneLineDefs: true 32 | 33 | Layout/IndentationWidth: 34 | Width: 2 35 | 36 | Style/LambdaCall: 37 | EnforcedStyle: call 38 | 39 | Layout/LeadingCommentSpace: 40 | Enabled: true 41 | 42 | Style/MethodName: 43 | EnforcedStyle: snake_case 44 | 45 | # ->(...) { ... } 46 | Layout/SpaceInLambdaLiteral: 47 | Enabled: true # Default is "require_no_space" -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.4.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.4.1' 4 | gem 'rails', '5.1.4' 5 | 6 | gem 'puma', '~> 3.0' 7 | gem 'uglifier' 8 | gem 'bcrypt' 9 | gem 'faker' 10 | gem 'graphql' 11 | gem 'graphql-batch' 12 | gem 'graphiql-rails' 13 | gem 'graphql-formatter' 14 | gem 'rack-cors' 15 | gem 'newrelic_rpm' 16 | 17 | group :production do 18 | gem 'pg' 19 | gem 'optics-agent' 20 | end 21 | 22 | group :development, :test do 23 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 24 | gem 'byebug', platform: :mri 25 | gem 'sqlite3' 26 | end 27 | 28 | group :development do 29 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 30 | gem 'spring' 31 | gem 'spring-watcher-listen', '~> 2.0.0' 32 | gem 'awesome_print' 33 | gem 'rubocop', require: false 34 | 35 | # for graphql browser 36 | gem 'sass-rails' 37 | gem 'uglifier' 38 | gem 'coffee-rails' 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (5.1.4) 5 | actionpack (= 5.1.4) 6 | nio4r (~> 2.0) 7 | websocket-driver (~> 0.6.1) 8 | actionmailer (5.1.4) 9 | actionpack (= 5.1.4) 10 | actionview (= 5.1.4) 11 | activejob (= 5.1.4) 12 | mail (~> 2.5, >= 2.5.4) 13 | rails-dom-testing (~> 2.0) 14 | actionpack (5.1.4) 15 | actionview (= 5.1.4) 16 | activesupport (= 5.1.4) 17 | rack (~> 2.0) 18 | rack-test (>= 0.6.3) 19 | rails-dom-testing (~> 2.0) 20 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 21 | actionview (5.1.4) 22 | activesupport (= 5.1.4) 23 | builder (~> 3.1) 24 | erubi (~> 1.4) 25 | rails-dom-testing (~> 2.0) 26 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 27 | activejob (5.1.4) 28 | activesupport (= 5.1.4) 29 | globalid (>= 0.3.6) 30 | activemodel (5.1.4) 31 | activesupport (= 5.1.4) 32 | activerecord (5.1.4) 33 | activemodel (= 5.1.4) 34 | activesupport (= 5.1.4) 35 | arel (~> 8.0) 36 | activesupport (5.1.4) 37 | concurrent-ruby (~> 1.0, >= 1.0.2) 38 | i18n (~> 0.7) 39 | minitest (~> 5.1) 40 | tzinfo (~> 1.1) 41 | arel (8.0.0) 42 | ast (2.3.0) 43 | awesome_print (1.8.0) 44 | bcrypt (3.1.11) 45 | builder (3.2.3) 46 | byebug (9.1.0) 47 | colorize (0.8.1) 48 | concurrent-ruby (1.0.5) 49 | crass (1.0.2) 50 | erubi (1.7.0) 51 | execjs (2.7.0) 52 | faker (1.8.4) 53 | i18n (~> 0.5) 54 | faraday (0.9.2) 55 | multipart-post (>= 1.2, < 3) 56 | ffi (1.9.18) 57 | globalid (0.4.0) 58 | activesupport (>= 4.2.0) 59 | google-protobuf (3.4.1.1) 60 | graphiql-rails (1.4.5) 61 | rails 62 | graphql (1.7.4) 63 | graphql-batch (0.3.5) 64 | graphql (>= 0.8, < 2) 65 | promise.rb (~> 0.7.2) 66 | graphql-formatter (0.1.1) 67 | colorize 68 | hitimes (1.2.6) 69 | i18n (0.9.0) 70 | concurrent-ruby (~> 1.0) 71 | listen (3.1.5) 72 | rb-fsevent (~> 0.9, >= 0.9.4) 73 | rb-inotify (~> 0.9, >= 0.9.7) 74 | ruby_dep (~> 1.2) 75 | loofah (2.1.1) 76 | crass (~> 1.0.2) 77 | nokogiri (>= 1.5.9) 78 | mail (2.6.6) 79 | mime-types (>= 1.16, < 4) 80 | method_source (0.9.0) 81 | mime-types (3.1) 82 | mime-types-data (~> 3.2015) 83 | mime-types-data (3.2016.0521) 84 | mini_portile2 (2.3.0) 85 | minitest (5.10.3) 86 | multipart-post (2.0.0) 87 | net-http-persistent (2.9.4) 88 | newrelic_rpm (4.5.0.337) 89 | nio4r (2.1.0) 90 | nokogiri (1.8.1) 91 | mini_portile2 (~> 2.3.0) 92 | optics-agent (0.5.5) 93 | faraday (~> 0.9.0, < 0.12) 94 | google-protobuf (~> 3.2) 95 | graphql (~> 1.1) 96 | hitimes (~> 1.2) 97 | net-http-persistent (~> 2.0) 98 | parallel (1.12.0) 99 | parser (2.4.0.0) 100 | ast (~> 2.2) 101 | pg (0.21.0) 102 | powerpack (0.1.1) 103 | promise.rb (0.7.4) 104 | puma (3.10.0) 105 | rack (2.0.3) 106 | rack-cors (1.0.1) 107 | rack-test (0.7.0) 108 | rack (>= 1.0, < 3) 109 | rails (5.1.4) 110 | actioncable (= 5.1.4) 111 | actionmailer (= 5.1.4) 112 | actionpack (= 5.1.4) 113 | actionview (= 5.1.4) 114 | activejob (= 5.1.4) 115 | activemodel (= 5.1.4) 116 | activerecord (= 5.1.4) 117 | activesupport (= 5.1.4) 118 | bundler (>= 1.3.0) 119 | railties (= 5.1.4) 120 | sprockets-rails (>= 2.0.0) 121 | rails-dom-testing (2.0.3) 122 | activesupport (>= 4.2.0) 123 | nokogiri (>= 1.6) 124 | rails-html-sanitizer (1.0.3) 125 | loofah (~> 2.0) 126 | railties (5.1.4) 127 | actionpack (= 5.1.4) 128 | activesupport (= 5.1.4) 129 | method_source 130 | rake (>= 0.8.7) 131 | thor (>= 0.18.1, < 2.0) 132 | rainbow (2.2.2) 133 | rake 134 | rake (12.1.0) 135 | rb-fsevent (0.10.2) 136 | rb-inotify (0.9.10) 137 | ffi (>= 0.5.0, < 2) 138 | rubocop (0.50.0) 139 | parallel (~> 1.10) 140 | parser (>= 2.3.3.1, < 3.0) 141 | powerpack (~> 0.1) 142 | rainbow (>= 2.2.2, < 3.0) 143 | ruby-progressbar (~> 1.7) 144 | unicode-display_width (~> 1.0, >= 1.0.1) 145 | ruby-progressbar (1.9.0) 146 | ruby_dep (1.5.0) 147 | spring (2.0.2) 148 | activesupport (>= 4.2) 149 | spring-watcher-listen (2.0.1) 150 | listen (>= 2.7, < 4.0) 151 | spring (>= 1.2, < 3.0) 152 | sprockets (3.7.1) 153 | concurrent-ruby (~> 1.0) 154 | rack (> 1, < 3) 155 | sprockets-rails (3.2.1) 156 | actionpack (>= 4.0) 157 | activesupport (>= 4.0) 158 | sprockets (>= 3.0.0) 159 | sqlite3 (1.3.13) 160 | thor (0.20.0) 161 | thread_safe (0.3.6) 162 | tzinfo (1.2.3) 163 | thread_safe (~> 0.1) 164 | uglifier (3.2.0) 165 | execjs (>= 0.3.0, < 3) 166 | unicode-display_width (1.3.0) 167 | websocket-driver (0.6.5) 168 | websocket-extensions (>= 0.1.0) 169 | websocket-extensions (0.1.2) 170 | 171 | PLATFORMS 172 | ruby 173 | 174 | DEPENDENCIES 175 | awesome_print 176 | bcrypt 177 | byebug 178 | faker 179 | graphiql-rails 180 | graphql 181 | graphql-batch 182 | graphql-formatter 183 | newrelic_rpm 184 | optics-agent 185 | pg 186 | puma (~> 3.0) 187 | rack-cors 188 | rails (= 5.1.4) 189 | rubocop 190 | spring 191 | spring-watcher-listen (~> 2.0.0) 192 | sqlite3 193 | uglifier 194 | 195 | RUBY VERSION 196 | ruby 2.4.1p111 197 | 198 | BUNDLED WITH 199 | 1.16.0 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matthieu Segret 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb -p $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Rails Blog 2 | 3 | This application can be used as **starter kit** if you want to get started building an app with **Rails**, **React**, 4 | and **GraphQL**. This is a simple blog engine using ordinary features which can be found in most web applications. 5 | 6 | If you are interested by Elixir, I created a similar application with Phoenix and Absinthe that you might like: 7 | [Yummy Phoenix GraphQL](https://github.com/MatthieuSegret/yummy-phoenix-graphql). 8 | 9 | ## Technologies 10 | 11 | ### Frontend 12 | 13 | * [TypeScript](https://github.com/Microsoft/TypeScript) - A superset of JavaScript that compiles to clean JavaScript 14 | output. 15 | * [React](https://facebook.github.io/react) - A JavaScript library for building user interfaces. It introduces many 16 | great concepts, such as, Virtual DOM, Data flow, etc. 17 | * [Create React App](https://github.com/facebookincubator/create-react-app) - is a new officially supported way to 18 | create single-page React applications. It offers a modern build setup with no configuration. 19 | * [Bulma](https://bulma.io) - Bulma is a modern CSS framework based on Flexbox 20 | * [Apollo 2](http://dev.apollodata.com/) - A flexible, fully-featured GraphQL client for every platform. 21 | * [React Final Form](https://github.com/erikras/react-final-form) - High performance subscription-based form state 22 | management for React. 23 | 24 | ### Backend 25 | 26 | * Ruby 2.4 27 | * Rails 5.1 28 | * [GraphQL-Ruby](https://github.com/rmosolgo/graphql-ruby) - GraphQL-Ruby is a Ruby implementation of 29 | [GraphQL](http://graphql.org). 30 | * [GraphQL-batch](https://github.com/Shopify/graphql-batch) - GraphQL-batch is a query batching executor for the graphql 31 | gem. 32 | * [Graphiql](https://github.com/graphql/graphiql) - Graphiql is an in-browser IDE for exploring GraphQL. 33 | * [Rack CORS](https://github.com/cyu/rack-cors) - Rack Middleware for handling Cross-Origin Resource Sharing (CORS), 34 | which makes cross-origin AJAX possible. 35 | * [Optics Agent](http://www.apollodata.com/optics) - Optics Agent for GraphQL Monitoring 36 | * SQLite3 for development and PostgreSQL for production. 37 | 38 | ## Features 39 | 40 | * CRUD (create / read / update / delete) on posts 41 | * Creating comments on post page 42 | * Pagination on posts listing 43 | * Searching on posts 44 | * Authentication with Devise and authorizations (visitors, users, admins) 45 | * Creating user account 46 | * Update user profile and changing password 47 | * Setup dev tools 48 | * Application ready for production 49 | 50 | ## GraphQL Using 51 | 52 | * Queries et mutations 53 | * FetchMore for pagination 54 | * Using `apollo-cache-inmemory` 55 | * Apollo Link (dedup, onError, auth) 56 | * [Managing local state](https://github.com/apollographql/apollo-link-state) with Apollo Link 57 | * Optimistic UI 58 | * [Static GraphQL queries](https://dev-blog.apollodata.com/5-benefits-of-static-graphql-queries-b7fa90b0b69a) 59 | * Validation management and integration with Final Form 60 | * Authentication and authorizations 61 | * Protect queries and mutations on GraphQL API 62 | * Batching of GraphQL queries into one HTTP request 63 | * Batching of SQL queries backend side 64 | 65 | ## Prerequisites 66 | 67 | * Ruby 2.4 68 | * Node 9.2 ([Installing Node](https://nodejs.org/en/download/package-manager)) 69 | * SQLite3 70 | 71 | ## Getting Started 72 | 73 | * Install Bundler 74 | 75 | $ gem install bundler 76 | 77 | * Checkout the graphql-rails-blog git tree from Github 78 | 79 | $ git clone https://github.com/MatthieuSegret/graphql-rails-blog.git 80 | $ cd graphql-rails-blog 81 | graphql-rails-blog$ 82 | 83 | * Run Bundler to install/bundle gems needed by the project: 84 | 85 | graphql-rails-blog$ bundle 86 | 87 | * Create the database: 88 | 89 | graphql-rails-blog$ rails db:migrate 90 | 91 | * Load sample records: 92 | 93 | graphql-rails-blog$ rails db:seed 94 | 95 | * Run the Rails server in development mode 96 | 97 | graphql-rails-blog$ rails server 98 | 99 | * Run Yarn to install javascript package in other terminal: 100 | 101 | graphql-rails-blog$ cd client 102 | graphql-rails-blog/client$ yarn 103 | 104 | * Start client in development mode. You should be able to go to `http://localhost:8080` : 105 | 106 | graphql-rails-blog/client$ yarn start 107 | 108 | ## Next step 109 | 110 | * [ ] Use subscription GraphQL feature 111 | * [ ] Create mobile app with React Native 112 | 113 | ## Screens 114 | 115 | #### Listing posts 116 | 117 | Listing posts 118 | 119 | #### Creating comments 120 | 121 | Creating comments 122 | 123 | #### Post page 124 | 125 | Post page 126 | 127 | #### Editing post 128 | 129 | Editing post 130 | 131 | ## License 132 | 133 | MIT © [Matthieu Segret](http://matthieusegret.com) 134 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Authenticatable 3 | before_action :authenticate 4 | 5 | def index 6 | render file: 'public/index.html' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/concerns/authenticatable.rb: -------------------------------------------------------------------------------- 1 | module Authenticatable 2 | def authenticate 3 | if request.headers["Authorization"].present? 4 | pattern = /^Bearer / 5 | header = request.headers['Authorization'] 6 | token = header.gsub(pattern, '') if header && header.match(pattern) 7 | 8 | if token.present? 9 | @current_user = User.find_by_access_token(token) 10 | 11 | if @current_user.nil? 12 | render json: FieldError.error("Auth token is invalid") 13 | end 14 | end 15 | end 16 | end 17 | 18 | def current_user 19 | @current_user 20 | end 21 | end -------------------------------------------------------------------------------- /app/controllers/graphql_controller.rb: -------------------------------------------------------------------------------- 1 | class GraphqlController < ApplicationController 2 | def create 3 | if params[:query].present? 4 | # Execute one query 5 | query_string, variables = params[:query], params[:variables] 6 | result = execute(query_string, variables) 7 | else 8 | # Execute multi queries 9 | queries_params = params[:_json] 10 | result = multiplex(queries_params) 11 | end 12 | 13 | render json: result 14 | end 15 | 16 | 17 | private 18 | 19 | # Execute one query 20 | def execute(query_string, variables) 21 | puts GraphQLFormatter.new(query_string) if Rails.env.development? 22 | query_variables = ensure_hash(params[:variables]) 23 | result = Schema.execute( 24 | query_string, 25 | variables: query_variables, 26 | context: { 27 | current_user: current_user, 28 | request: request 29 | }) 30 | end 31 | 32 | # Execute multi queries 33 | def multiplex(queries_params) 34 | if Rails.env.development? 35 | queries_params.each { |query| puts GraphQLFormatter.new(query[:query]) } 36 | end 37 | 38 | queries = queries_params.map do |query| 39 | { 40 | query: query[:query], 41 | variables: ensure_hash(query[:variables]), 42 | context: { 43 | current_user: current_user, 44 | request: request, 45 | optics_agent: (Rails.env.production? ? request.env[:optics_agent].with_document(query[:query]) : nil) 46 | } 47 | } 48 | end 49 | 50 | Schema.multiplex(queries) 51 | end 52 | 53 | def ensure_hash(query_variables) 54 | if query_variables.blank? 55 | {} 56 | elsif query_variables.is_a?(String) 57 | JSON.parse(query_variables) 58 | else 59 | query_variables 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/graph/mutations/auth_mutations.rb: -------------------------------------------------------------------------------- 1 | module AuthMutations 2 | SignIn = GraphQL::Relay::Mutation.define do 3 | name "signIn" 4 | description "SignIn" 5 | 6 | input_field :email, types.String 7 | input_field :password, types.String 8 | 9 | return_field :token, types.String 10 | return_field :messages, types[FieldErrorType] 11 | 12 | resolve ->(obj, inputs, ctx) { 13 | user = User.find_by(email: inputs[:email]) 14 | if user.present? && user.authenticate(inputs[:password]) 15 | user.update_tracked_fields(ctx[:request]) 16 | { token: user.generate_access_token! } 17 | else 18 | FieldError.error("Invalid email or password") 19 | end 20 | } 21 | end 22 | 23 | RevokeToken = GraphQL::Relay::Mutation.define do 24 | name "revokeToken" 25 | description "revoke token" 26 | return_field :messages, types[FieldErrorType] 27 | 28 | resolve(Auth.protect ->(obj, inputs, ctx) { 29 | ctx[:current_user].update(access_token: nil) 30 | {} 31 | }) 32 | end 33 | end -------------------------------------------------------------------------------- /app/graph/mutations/comment_mutations.rb: -------------------------------------------------------------------------------- 1 | module CommentMutations 2 | Create = GraphQL::Relay::Mutation.define do 3 | name "createComment" 4 | description "create Comment" 5 | 6 | input_field :postId, types.ID 7 | input_field :content, types.String 8 | 9 | return_field :comment, CommentType 10 | return_field :messages, types[FieldErrorType] 11 | 12 | resolve(Auth.protect ->(obj, inputs, ctx) { 13 | post = Post.find(inputs[:postId]) 14 | new_comment = post.comments.build(content: inputs[:content]) 15 | new_comment.user = ctx[:current_user] 16 | 17 | if new_comment.save 18 | { comment: new_comment } 19 | else 20 | { messages: new_comment.fields_errors } 21 | end 22 | }) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/graph/mutations/post_mutations.rb: -------------------------------------------------------------------------------- 1 | module PostMutations 2 | Create = GraphQL::Relay::Mutation.define do 3 | name "createPost" 4 | description "create Post" 5 | 6 | input_field :title, types.String 7 | input_field :content, types.String 8 | 9 | return_field :post, PostType 10 | return_field :messages, types[FieldErrorType] 11 | 12 | resolve(Auth.protect ->(obj, inputs, ctx) { 13 | new_post = ctx[:current_user].posts.build(inputs.to_params) 14 | 15 | if new_post.save 16 | { post: new_post } 17 | else 18 | { messages: new_post.fields_errors } 19 | end 20 | }) 21 | end 22 | 23 | Update = GraphQL::Relay::Mutation.define do 24 | name "updatePost" 25 | description "Update a Post and return Post" 26 | 27 | input_field :id, types.ID 28 | input_field :title, types.String 29 | input_field :content, types.String 30 | 31 | return_field :post, PostType 32 | return_field :messages, types[FieldErrorType] 33 | 34 | resolve(Auth.protect ->(obj, inputs, ctx) { 35 | post = Post.find(inputs[:id]) 36 | 37 | if ctx[:current_user] != post.user 38 | FieldError.error("You can not modify this post because you are not the owner") 39 | elsif post.update(inputs.to_params) 40 | { post: post } 41 | else 42 | { messages: post.fields_errors } 43 | end 44 | }) 45 | end 46 | 47 | Destroy = GraphQL::Relay::Mutation.define do 48 | name "deletePost" 49 | description "Destroy a Post" 50 | input_field :id, types.ID 51 | 52 | return_field :post, PostType 53 | return_field :messages, types[FieldErrorType] 54 | 55 | resolve(Auth.protect ->(obj, inputs, ctx) { 56 | post = Post.find(inputs[:id]) 57 | 58 | if ctx[:current_user] != post.user 59 | FieldError.error("You can not modify this post because you are not the owner") 60 | elsif post.destroy 61 | { post: post } 62 | end 63 | }) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /app/graph/mutations/user_mutations.rb: -------------------------------------------------------------------------------- 1 | module UserMutations 2 | SignUp = GraphQL::Relay::Mutation.define do 3 | name "signUp" 4 | description "Sign up" 5 | 6 | input_field :name, types.String 7 | input_field :email, types.String 8 | input_field :password, types.String 9 | input_field :password_confirmation, types.String 10 | 11 | return_field :user, UserType 12 | return_field :messages, types[FieldErrorType] 13 | 14 | resolve ->(obj, inputs, ctx) { 15 | user = User.new(inputs.to_params) 16 | 17 | if user.save 18 | user.update_tracked_fields(ctx[:request]) 19 | user.generate_access_token! 20 | { user: user } 21 | else 22 | { messages: user.fields_errors } 23 | end 24 | } 25 | end 26 | 27 | Update = GraphQL::Relay::Mutation.define do 28 | name "updateUser" 29 | description "Update user" 30 | 31 | input_field :name, types.String 32 | input_field :email, types.String 33 | 34 | return_field :user, UserType 35 | return_field :messages, types[FieldErrorType] 36 | 37 | resolve(Auth.protect ->(obj, inputs, ctx) { 38 | current_user = ctx[:current_user] 39 | 40 | if current_user.update(inputs.to_params) 41 | { user: current_user } 42 | else 43 | { messages: current_user.fields_errors } 44 | end 45 | }) 46 | end 47 | 48 | ChangePassword = GraphQL::Relay::Mutation.define do 49 | name "changePassword" 50 | description "Change user password" 51 | 52 | input_field :password, types.String 53 | input_field :password_confirmation, types.String 54 | input_field :current_password, types.String 55 | 56 | return_field :user, UserType 57 | return_field :messages, types[FieldErrorType] 58 | 59 | resolve(Auth.protect ->(obj, inputs, ctx) { 60 | current_user = ctx[:current_user] 61 | params_with_password = inputs.to_h.symbolize_keys 62 | if current_user.update_with_password(params_with_password) 63 | { user: current_user } 64 | else 65 | { messages: current_user.fields_errors } 66 | end 67 | }) 68 | end 69 | 70 | CancelAccount = GraphQL::Relay::Mutation.define do 71 | name "cancelAccount" 72 | description "Cancel Account" 73 | 74 | return_type types.Boolean 75 | 76 | resolve(Auth.protect ->(obj, inputs, ctx) { 77 | ctx[:current_user].destroy 78 | true 79 | }) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /app/graph/schema.rb: -------------------------------------------------------------------------------- 1 | Schema = GraphQL::Schema.define do 2 | query QueryType 3 | mutation MutationType 4 | 5 | instrument :query, ConcurrentBatchSetup 6 | lazy_resolve GraphQL::Batch::Promise, :sync 7 | end 8 | -------------------------------------------------------------------------------- /app/graph/types/comment_type.rb: -------------------------------------------------------------------------------- 1 | CommentType = GraphQL::ObjectType.define do 2 | 3 | name "Comment" 4 | description "A post comment with content and author" 5 | 6 | field :id, !types.ID 7 | field :content, types.String, "The content of this comment" 8 | field :created_at, types.String, "The date on which the comment was posted" 9 | 10 | field :author, UserType, "Owner of this comment" do 11 | resolve ->(comment, args, ctx) { 12 | RecordLoader.for(User).load(comment.user_id) 13 | } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/graph/types/field_error_type.rb: -------------------------------------------------------------------------------- 1 | FieldErrorType = GraphQL::ObjectType.define do 2 | name "FieldError" 3 | description "Information about field that didn’t pass validation" 4 | 5 | # Expose fields from the model 6 | field :field, types.String, "Field name that caused these errors" 7 | field :message, !types.String, "Validation message" 8 | end 9 | -------------------------------------------------------------------------------- /app/graph/types/mutation_type.rb: -------------------------------------------------------------------------------- 1 | MutationType = GraphQL::ObjectType.define do 2 | name 'Mutation' 3 | 4 | field :createPost, field: PostMutations::Create.field 5 | field :updatePost, field: PostMutations::Update.field 6 | field :deletePost, field: PostMutations::Destroy.field 7 | 8 | field :createComment, field: CommentMutations::Create.field 9 | 10 | field :signIn, field: AuthMutations::SignIn.field 11 | field :revokeToken, field: AuthMutations::RevokeToken.field 12 | 13 | field :signUp, field: UserMutations::SignUp.field 14 | field :updateUser, field: UserMutations::Update.field 15 | field :changePassword, field: UserMutations::ChangePassword.field 16 | field :cancelAccount, field: UserMutations::CancelAccount.field 17 | end 18 | -------------------------------------------------------------------------------- /app/graph/types/post_type.rb: -------------------------------------------------------------------------------- 1 | PostType = GraphQL::ObjectType.define do 2 | name "Post" 3 | description "A blog post with title, content, author and total comments" 4 | 5 | field :id, !types.ID 6 | field :title, types.String, "The title of this post" 7 | field :content, types.String, "The content of this post" 8 | field :comments_count, types.String, "The total numner of comments on this post" 9 | field :created_at, types.String, "The time at which this post was created" 10 | field :description, types.String, "The beginning of the content" 11 | 12 | field :author, UserType, "Owner of this post" do 13 | resolve ->(post, args, ctx) { 14 | RecordLoader.for(User).load(post.user_id) 15 | } 16 | end 17 | 18 | field :comments do 19 | type !types[!CommentType] 20 | description "All comments association with this post." 21 | resolve ->(post, args, ctx){ 22 | post.comments.order(created_at: :desc) 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/graph/types/query_type.rb: -------------------------------------------------------------------------------- 1 | QueryType = GraphQL::ObjectType.define do 2 | name "Query" 3 | description "The query root of this schema. See available queries." 4 | 5 | field :posts do 6 | type types[PostType] 7 | description 'Fetch paginated posts collection' 8 | argument :offset, types.Int, default_value: 0 9 | argument :keywords, types.String, default_value: nil 10 | resolve ->(object, args, ctx) { 11 | Post 12 | .search(args[:keywords]) 13 | .paginate(args[:offset]) 14 | .order(created_at: :desc) 15 | } 16 | end 17 | 18 | field :postsCount do 19 | type types.Int 20 | description 'Number of post' 21 | argument :keywords, types.String, default_value: nil 22 | resolve ->(object, args, ctx) { 23 | Post.search(args[:keywords]).count 24 | } 25 | end 26 | 27 | field :post, PostType do 28 | argument :id, types.ID 29 | description 'fetch a Post by id' 30 | resolve ->(object, args, ctx){ 31 | Post.find(args[:id]) 32 | } 33 | end 34 | 35 | field :currentUser, UserType do 36 | description 'fetch the current user.' 37 | resolve ->(object, args, ctx){ 38 | ctx[:current_user] 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/graph/types/user_type.rb: -------------------------------------------------------------------------------- 1 | UserType = GraphQL::ObjectType.define do 2 | name "User" 3 | description "An user entry, returns basic user information" 4 | 5 | field :id, !types.ID 6 | field :name, types.String, "The name of this user" 7 | field :email, types.String, "The email of this user" 8 | field :created_at, types.String, "The date this user created an account" 9 | field :token, types.String, "Access token" do 10 | resolve ->(user, args, ctx) { 11 | user.access_token 12 | } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/graph/utils/auth.rb: -------------------------------------------------------------------------------- 1 | module Auth 2 | def self.protect(resolve) 3 | ->(obj, args, ctx) do 4 | if ctx[:current_user] 5 | resolve.call(obj, args, ctx) 6 | else 7 | FieldError.error("You need to sign in or sign up before continuing") 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/graph/utils/concurrent_batch_setup.rb: -------------------------------------------------------------------------------- 1 | module ConcurrentBatchSetup 2 | module_function 3 | 4 | def before_query(query) 5 | # Use the same executor for batch queries 6 | GraphQL::Batch::Executor.current ||= GraphQL::Batch::Executor.new 7 | end 8 | 9 | def after_query(query) 10 | GraphQL::Batch::Executor.current = nil 11 | end 12 | end -------------------------------------------------------------------------------- /app/graph/utils/record_loader.rb: -------------------------------------------------------------------------------- 1 | class RecordLoader < GraphQL::Batch::Loader 2 | def initialize(model) 3 | @model = model 4 | end 5 | 6 | def perform(ids) 7 | @model.where(id: ids).each { |record| fulfill(record.id, record) } 8 | ids.each { |id| fulfill(id, nil) unless fulfilled?(id) } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | 4 | def fields_errors 5 | field_errors = [] 6 | self.errors.each do |attr, msg| 7 | field_errors.push(FieldError.new(attr.to_s, msg)) 8 | end 9 | field_errors 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < ApplicationRecord 2 | 3 | validates :content, presence: true 4 | 5 | belongs_to :user, optional: false 6 | belongs_to :post, touch: true, counter_cache: true 7 | end 8 | -------------------------------------------------------------------------------- /app/models/concerns/trackable.rb: -------------------------------------------------------------------------------- 1 | module Trackable 2 | def update_tracked_fields(request) 3 | old_current, new_current = self.current_sign_in_at, Time.now.utc 4 | self.last_sign_in_at = old_current || new_current 5 | self.current_sign_in_at = new_current 6 | 7 | old_current, new_current = self.current_sign_in_ip, request.remote_ip 8 | self.last_sign_in_ip = old_current || new_current 9 | self.current_sign_in_ip = new_current 10 | 11 | self.sign_in_count ||= 0 12 | self.sign_in_count += 1 13 | self.save! 14 | end 15 | end -------------------------------------------------------------------------------- /app/models/field_error.rb: -------------------------------------------------------------------------------- 1 | class FieldError < Struct.new(:field, :message) 2 | def self.error(msg) 3 | {messages: [FieldError.new("base", msg)]} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | 3 | validates :title, :content, presence: true 4 | validates :title, length: { minimum: 3 } 5 | cattr_accessor(:paginates_per) { 10 } 6 | 7 | has_many :comments, dependent: :destroy 8 | belongs_to :user 9 | 10 | def self.paginate(offset) 11 | offset(offset).limit(self.paginates_per) 12 | end 13 | 14 | def self.search(keywords) 15 | return self if keywords.blank? 16 | where('lower(title) like :keywords OR lower(content) like :keywords', :keywords => "%#{keywords.downcase}%") 17 | end 18 | 19 | def description 20 | self.content.truncate(180) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | include Trackable 3 | has_secure_password 4 | 5 | validates :name, :email, :password_digest, presence: true 6 | validates :email, uniqueness: true, format: /@/ 7 | validates :password, length: { minimum: 6 }, allow_blank: true 8 | 9 | has_many :posts, dependent: :destroy 10 | has_many :comments, dependent: :destroy 11 | 12 | def generate_access_token! 13 | self.access_token = Digest::SHA1.hexdigest("#{Time.now}-#{self.id}-#{SecureRandom.hex}") 14 | self.save! 15 | self.access_token 16 | end 17 | 18 | def update_with_password(password: nil, password_confirmation: nil, current_password: nil) 19 | if self.authenticate(current_password) 20 | self.errors.add(:base, "Password can't be blank") && (return false) if password.blank? 21 | self.update(password: password, password_confirmation: password_confirmation) 22 | else 23 | self.errors.add(:current_password, "Invalid password") 24 | return false 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # Install JavaScript dependencies if using Yarn 22 | # system('bin/yarn') 23 | 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:setup' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | if spring = lockfile.specs.detect { |spec| spec.name == "spring" } 12 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 13 | gem 'spring', spring.version 14 | require 'spring/binstub' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | VENDOR_PATH = File.expand_path('..', __dir__) 3 | Dir.chdir(VENDOR_PATH) do 4 | begin 5 | exec "yarnpkg #{ARGV.join(" ")}" 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | NODE_PATH=src 2 | PUBLIC_URL=/ 3 | PORT=8080 4 | BROWSER=none 5 | REACT_EDITOR=code -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | schema.json -------------------------------------------------------------------------------- /client/config-overrides.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CompressionPlugin = require('compression-webpack-plugin'); 3 | const { getLoader } = require('react-app-rewired'); 4 | 5 | function rewireGraphQLTag(config, env) { 6 | const gqlExtension = /\.(graphql|gql)$/; 7 | 8 | // Exclude .graphql files from the file-loader 9 | const fileLoader = getLoader( 10 | config.module.rules, 11 | rule => 12 | rule.loader && typeof rule.loader === 'string' && rule.loader.indexOf(`${path.sep}file-loader${path.sep}`) !== -1 13 | ); 14 | fileLoader.exclude.push(gqlExtension); 15 | 16 | // Add loader for graphQL files 17 | const graphQLRule = { 18 | test: gqlExtension, 19 | loader: 'graphql-tag/loader', 20 | exclude: /node_modules/ 21 | }; 22 | config.module.rules.push(graphQLRule); 23 | config.resolve.extensions.push('.graphql'); 24 | 25 | return config; 26 | } 27 | 28 | module.exports = function override(config, env) { 29 | config.resolve.extensions.push('.ts', '.tsx'); 30 | coconfignf = rewireGraphQLTag(config, env); 31 | config.plugins.push( 32 | new CompressionPlugin({ 33 | algorithm: 'gzip' 34 | }) 35 | ); 36 | 37 | return config; 38 | }; 39 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-rails-blog", 3 | "version": "1.0.0", 4 | "description": "Blog App build with Rails 5, React and GraphQL", 5 | "homepage": "https://github.com/MatthieuSegret/graphql-rails-blog", 6 | "engines": { 7 | "node": "9.2.0", 8 | "npm": "5.5.1" 9 | }, 10 | "author": "Matthieu Segret ", 11 | "license": "MIT", 12 | "scripts": { 13 | "build-css": "node-sass --include-path node_modules/ --include-path src/ src/assets/stylesheets/scss -o src/assets/stylesheets/css", 14 | "watch-css": "npm run build-css && node-sass --include-path node_modules/ --include-path src/ src/assets/stylesheets/scss -o src/assets/stylesheets/css --watch --recursive", 15 | "start-js": "react-app-rewired start --scripts-version react-scripts-ts", 16 | "start": "npm-run-all -p watch-css start-js", 17 | "build": "npm run build-css && react-app-rewired build --scripts-version react-scripts-ts", 18 | "test": "react-app-rewired test --scripts-version react-scripts-ts --env=jsdom" 19 | }, 20 | "dependencies": { 21 | "apollo-cache-inmemory": "^1.0.0", 22 | "apollo-client": "^2.0.1", 23 | "apollo-link": "^1.0.3", 24 | "apollo-link-batch-http": "^1.0.1", 25 | "apollo-link-context": "^1.0.0", 26 | "apollo-link-dedup": "^1.0.2", 27 | "apollo-link-error": "^1.0.0", 28 | "apollo-link-state": "0.1.0", 29 | "apollo-mutation-state": "^0.0.7", 30 | "bulma": "^0.6.1", 31 | "classnames": "^2.2.5", 32 | "final-form": "1.3.5", 33 | "font-awesome": "^4.7.0", 34 | "graphql": "0.12.3", 35 | "graphql-tag": "^2.5.0", 36 | "moment": "2.20.0", 37 | "react": "16.2.0", 38 | "react-apollo": "2.0.4", 39 | "react-dom": "16.2.0", 40 | "react-final-form": "1.2.1", 41 | "react-router-dom": "4.2.2", 42 | "react-scripts-ts": "^2.8.0", 43 | "shortid": "2.2.8" 44 | }, 45 | "devDependencies": { 46 | "@types/classnames": "^2.2.3", 47 | "@types/graphql": "^0.11.7", 48 | "@types/node": "^8.5.1", 49 | "@types/react": "^16.0.31", 50 | "@types/react-dom": "^16.0.3", 51 | "@types/react-router": "^4.0.19", 52 | "@types/react-router-dom": "^4.2.3", 53 | "@types/shortid": "^0.0.29", 54 | "compression-webpack-plugin": "^1.0.1", 55 | "node-sass": "^4.5.3", 56 | "npm-run-all": "4.1.2", 57 | "prettier": "1.9.2", 58 | "react-app-rewired": "^1.3.5", 59 | "tslint-config-prettier": "^1.6.0", 60 | "typescript": "^2.6.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthieuSegret/graphql-rails-blog/6423881bec223522f8af09cd4b18905ca20fef0f/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GraphQL Rails Blog 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-rails-blog", 3 | "short_name": "graphql-rails-blog", 4 | "description": null, 5 | "dir": "auto", 6 | "lang": "en-US", 7 | "display": "standalone", 8 | "orientation": "any", 9 | "icons": [ 10 | { 11 | "src": "favicon.ico", 12 | "sizes": "64x64", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "./index.html", 17 | "theme_color": "#000000", 18 | "background_color": "#ffffff" 19 | } -------------------------------------------------------------------------------- /client/src/assets/stylesheets/scss/_layout.scss: -------------------------------------------------------------------------------- 1 | header, 2 | .container nav.navbar { 3 | background-color: $primary; 4 | } 5 | 6 | a.navbar-item, 7 | .navbar-link { 8 | color: $primary-invert; 9 | &:hover { 10 | color: $primary-invert; 11 | background-color: darken($primary, 5%); 12 | } 13 | } 14 | 15 | .navbar-brand .navbar-item { 16 | padding-top: 0; 17 | padding-bottom: 0; 18 | } 19 | 20 | a .icon { 21 | vertical-align: top; 22 | } 23 | -------------------------------------------------------------------------------- /client/src/assets/stylesheets/scss/_posts.scss: -------------------------------------------------------------------------------- 1 | .posts { 2 | margin-top: 40px; 3 | margin-bottom: 40px; 4 | 5 | .post { 6 | margin-bottom: 30px; 7 | .title-wrapper .title { 8 | margin-bottom: 0; 9 | a { 10 | color: $dark; 11 | &:hover { 12 | color: $link; 13 | } 14 | } 15 | } 16 | } 17 | 18 | .load-more { 19 | display: block; 20 | width: 200px; 21 | margin-left: auto; 22 | margin-right: auto; 23 | } 24 | } 25 | 26 | .post-show { 27 | .title-wrapper .title { 28 | margin-bottom: 10px; 29 | } 30 | .post-content { 31 | .post-image { 32 | display: block; 33 | margin-bottom: 20px; 34 | height: 250px; 35 | } 36 | } 37 | } 38 | 39 | .post { 40 | .title-wrapper .title { 41 | display: inline-block; 42 | } 43 | 44 | .post-info { 45 | color: $grey; 46 | } 47 | 48 | .post-actions a { 49 | margin-left: 10px; 50 | } 51 | 52 | .comments { 53 | margin-top: 40px; 54 | .comment { 55 | margin-bottom: 20px; 56 | 57 | .comment-meta { 58 | color: #999; 59 | .comment-author, 60 | .comment-date { 61 | margin-right: 20px; 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/assets/stylesheets/scss/_users.scss: -------------------------------------------------------------------------------- 1 | .change-password, 2 | .cancel-account { 3 | margin-top: 40px; 4 | h3.title { 5 | margin-bottom: 0.5rem; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/assets/stylesheets/scss/application.scss: -------------------------------------------------------------------------------- 1 | @import 'bulma/sass/utilities/initial-variables'; 2 | $primary: $cyan; 3 | $link: $cyan; 4 | $success: $turquoise; 5 | $fullhd: $widescreen; 6 | @import '~font-awesome/css/font-awesome.css'; 7 | @import 'bulma/bulma'; 8 | 9 | @import '_layout'; 10 | @import '_users'; 11 | @import '_posts'; 12 | -------------------------------------------------------------------------------- /client/src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default class NotFound extends React.Component<{}, {}> { 4 | public render() { 5 | return ( 6 |
7 |

404 page not found

8 |

We are sorry but the page you are looking for does not exist.

9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/components/ScrollToTop.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | class ScrollToTop extends React.Component { 5 | public componentDidUpdate(prevProps: any) { 6 | if (this.props.location !== prevProps.location) { 7 | window.scrollTo(0, 0); 8 | } 9 | } 10 | 11 | public render() { 12 | return this.props.children; 13 | } 14 | } 15 | 16 | export default withRouter(ScrollToTop); 17 | -------------------------------------------------------------------------------- /client/src/components/UserIsAuthenticated.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { compose } from 'react-apollo'; 3 | 4 | import withFlashMessage from 'components/flash/withFlashMessage'; 5 | import withCurrentUser from 'queries/currentUserQuery'; 6 | 7 | // typings 8 | import { User, FlashMessageVariables } from 'types'; 9 | 10 | interface IProps { 11 | redirect: (path: string, message: FlashMessageVariables) => void; 12 | currentUser: User; 13 | currentUserLoading: boolean; 14 | } 15 | 16 | export default function UserIsAuthenticated(WrappedComponent: React.ComponentType) { 17 | class ComponentUserIsAuthenticated extends React.Component { 18 | constructor(props: IProps) { 19 | super(props); 20 | this.redirectIfUserIsNotAuthenticated = this.redirectIfUserIsNotAuthenticated.bind(this); 21 | } 22 | 23 | public componentWillMount() { 24 | this.redirectIfUserIsNotAuthenticated(); 25 | } 26 | 27 | public componentWillReceiveProps(nextProps: IProps) { 28 | this.redirectIfUserIsNotAuthenticated(nextProps); 29 | } 30 | 31 | private redirectIfUserIsNotAuthenticated(props?: IProps) { 32 | const { currentUser, currentUserLoading } = props || this.props; 33 | if (!currentUserLoading && !currentUser) { 34 | this.props.redirect('/users/signin', { 35 | error: 'You need to sign in or sign up before continuing.' 36 | }); 37 | } 38 | } 39 | 40 | public render() { 41 | const { currentUser, currentUserLoading } = this.props; 42 | if (currentUserLoading || !currentUser) { 43 | return null; 44 | } 45 | return ; 46 | } 47 | } 48 | 49 | return compose(withCurrentUser, withFlashMessage)(ComponentUserIsAuthenticated); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/flash/FlashMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { graphql, compose } from 'react-apollo'; 3 | import classnames from 'classnames'; 4 | 5 | import withFlashMessage from 'components/flash/withFlashMessage'; 6 | import FLASH_MESSAGE from 'graphql/flash/flashMessageQuery.graphql'; 7 | 8 | // typings 9 | import { FlashMessageQuery } from 'types'; 10 | 11 | interface IProps { 12 | data: FlashMessageQuery; 13 | deleteFlashMessage: () => void; 14 | } 15 | 16 | class FlashMessage extends React.Component { 17 | constructor(props: IProps) { 18 | super(props); 19 | this.onClick = this.onClick.bind(this); 20 | } 21 | 22 | private onClick() { 23 | this.props.deleteFlashMessage(); 24 | } 25 | 26 | public render() { 27 | const { message } = this.props.data; 28 | if (!message) { 29 | return null; 30 | } 31 | const { type, text } = message; 32 | 33 | return ( 34 |
40 |
43 | ); 44 | } 45 | } 46 | 47 | export default compose(graphql(FLASH_MESSAGE), withFlashMessage)(FlashMessage); 48 | -------------------------------------------------------------------------------- /client/src/components/flash/flashMessageLocalLink.ts: -------------------------------------------------------------------------------- 1 | import { withClientState } from 'apollo-link-state'; 2 | 3 | import FLASH_MESSAGE from 'graphql/flash/flashMessageQuery.graphql'; 4 | 5 | // typings 6 | import { DataProxy } from 'apollo-cache'; 7 | import { FlashMessage } from 'types'; 8 | 9 | export const flashMessageLocalLink = withClientState({ 10 | Query: { 11 | // provide an initial state 12 | message: () => null 13 | }, 14 | Mutation: { 15 | // update values in the store on mutations 16 | createFlashMessage(_: any, { type, text }: FlashMessage, { cache }: { cache: DataProxy }) { 17 | const data = { 18 | message: { type, text, __typename: 'FlashMessage' } 19 | }; 20 | cache.writeQuery({ query: FLASH_MESSAGE, data }); 21 | return null; 22 | }, 23 | deleteFlashMessage(_: any, {}, { cache }: { cache: DataProxy }) { 24 | const data = { 25 | message: null 26 | }; 27 | cache.writeQuery({ query: FLASH_MESSAGE, data }); 28 | return null; 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /client/src/components/flash/withFlashMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { graphql, compose } from 'react-apollo'; 3 | import { withRouter } from 'react-router'; 4 | 5 | import CREATE_FLASH_MESSAGE from 'graphql/flash/createFlashMessageMutation.graphql'; 6 | import DELETE_FLASH_MESSAGE from 'graphql/flash/deleteFlashMessageMutation.graphql'; 7 | 8 | // typings 9 | import { History } from 'history'; 10 | import { FlashMessage, FlashMessageVariables } from 'types'; 11 | 12 | interface IProps { 13 | createFlashMessage: (message: FlashMessage) => void; 14 | deleteFlashMessage: () => void; 15 | history: History; 16 | } 17 | 18 | interface IWrapProps { 19 | redirect: (path: string, message: FlashMessageVariables) => void; 20 | notice: (text: string) => void; 21 | error: (text: string) => void; 22 | } 23 | 24 | export default function withFlashMessage(WrappedComponent: React.ComponentType) { 25 | class ComponentWithFlashMessage extends React.Component { 26 | constructor(props: IProps) { 27 | super(props); 28 | this.notice = this.notice.bind(this); 29 | this.error = this.error.bind(this); 30 | this.redirect = this.redirect.bind(this); 31 | } 32 | 33 | public notice(text: string) { 34 | this.props.createFlashMessage({ type: 'notice', text }); 35 | } 36 | 37 | public error(text: string) { 38 | this.props.createFlashMessage({ type: 'error', text }); 39 | } 40 | 41 | public deleteFlashMessage() { 42 | this.props.deleteFlashMessage(); 43 | } 44 | 45 | public redirect(path: string, message: FlashMessageVariables) { 46 | this.props.history.push(path); 47 | if (message && message.error) { 48 | this.error(message.error); 49 | } 50 | if (message && message.notice) { 51 | this.notice(message.notice); 52 | } 53 | } 54 | 55 | public render() { 56 | return ; 57 | } 58 | } 59 | 60 | const withCreateFlashMessage = graphql(CREATE_FLASH_MESSAGE, { 61 | props: ({ mutate }) => ({ 62 | createFlashMessage(message: FlashMessage) { 63 | return mutate!({ variables: { ...message } }); 64 | } 65 | }) 66 | }); 67 | 68 | const withDeleteFlashMessage = graphql(DELETE_FLASH_MESSAGE, { 69 | props: ({ mutate }) => ({ 70 | deleteFlashMessage() { 71 | return mutate!({}); 72 | } 73 | }) 74 | }); 75 | 76 | return compose(withCreateFlashMessage, withDeleteFlashMessage, withRouter)(ComponentWithFlashMessage); 77 | } 78 | -------------------------------------------------------------------------------- /client/src/components/form/RenderField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import classnames from 'classnames'; 3 | import capitalize from 'utils/stringUtils'; 4 | 5 | // typings 6 | import { IFormInput, IMeta, IOption } from 'components/form/renderField.d'; 7 | 8 | interface IProps { 9 | input: IFormInput; 10 | meta: IMeta; 11 | type: string; 12 | inputHtml?: any; 13 | options?: Array; 14 | className?: string; 15 | label?: string; 16 | hint?: string; 17 | name: string; 18 | } 19 | 20 | interface IState { 21 | label: string; 22 | } 23 | 24 | export default class RenderField extends React.Component { 25 | public static defaultProps = { 26 | type: 'text', 27 | label: '', 28 | options: [], 29 | inputHtml: { 30 | className: '' 31 | } 32 | }; 33 | 34 | constructor(props: IProps) { 35 | super(props); 36 | const { label, input: { name } } = this.props; 37 | this.state = { label: label || capitalize(name) }; 38 | this.input = this.input.bind(this); 39 | this.stateClass = this.stateClass.bind(this); 40 | this.defaultWrapper = this.defaultWrapper.bind(this); 41 | this.checkboxWrapper = this.checkboxWrapper.bind(this); 42 | this.fileWrapper = this.fileWrapper.bind(this); 43 | } 44 | 45 | private input(type: string, input: IFormInput, inputHtml: any, selectOptions?: Array): JSX.Element { 46 | let inputClass = classnames(inputHtml.className, this.stateClass()); 47 | 48 | switch (type) { 49 | case 'textarea': 50 | inputClass = classnames('textarea', inputClass); 51 | return