├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── Procfile.dev ├── README.md ├── Rakefile ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── config │ │ └── manifest.js │ ├── images │ │ └── .keep │ └── stylesheets │ │ └── .keep ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── api │ │ └── events_controller.rb │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ └── site_controller.rb ├── helpers │ ├── application_helper.rb │ └── site_helper.rb ├── javascript │ ├── application.js │ ├── components │ │ ├── App.css │ │ ├── App.js │ │ ├── Editor.js │ │ ├── Event.js │ │ ├── EventForm.js │ │ ├── EventList.js │ │ ├── EventNotFound.js │ │ └── Header.js │ ├── controllers │ │ ├── application.js │ │ ├── hello_controller.js │ │ └── index.js │ └── helpers │ │ ├── helpers.js │ │ └── notifications.js ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ └── event.rb └── views │ ├── layouts │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ └── site │ └── index.html.erb ├── bin ├── bundle ├── dev ├── rails ├── rake └── setup ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── assets.rb │ ├── content_security_policy.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ └── permissions_policy.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb └── storage.yml ├── db ├── migrate │ └── 20220313134908_create_events.rb ├── schema.rb ├── seeds.rb └── seeds │ └── events.json ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── log └── .keep ├── package-lock.json ├── package.json ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ ├── .keep │ └── site_controller_test.rb ├── fixtures │ ├── events.yml │ └── files │ │ └── .keep ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ └── event_test.rb ├── system │ └── .keep └── test_helper.rb ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep └── vendor └── .keep /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['airbnb', 'airbnb/hooks', 'prettier'], 4 | rules: { 5 | 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }], 6 | 'react/function-component-definition': [ 7 | 1, 8 | { namedComponents: 'arrow-function' }, 9 | ], 10 | 'no-console': 0, 11 | 'no-alert': 0, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /.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-* 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | # Ignore uploaded files in development. 26 | /storage/* 27 | !/storage/.keep 28 | /tmp/storage/* 29 | !/tmp/storage/ 30 | !/tmp/storage/.keep 31 | 32 | /public/assets 33 | 34 | # Ignore master key for decrypting credentials and more. 35 | /config/master.key 36 | 37 | /app/assets/builds/* 38 | !/app/assets/builds/.keep 39 | 40 | /node_modules 41 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rails 2 | 3 | AllCops: 4 | DisplayCopNames: true 5 | DisplayStyleGuide: true 6 | ExtraDetails: true 7 | TargetRubyVersion: 3.1 8 | Exclude: 9 | - bin/**/* 10 | - config/environments/**/* 11 | - config/initializers/**/* 12 | - config/application.rb 13 | - config/boot.rb 14 | - config/environment.rb 15 | - config/puma.rb 16 | - db/migrate/**/* 17 | - db/schema.rb 18 | 19 | Layout/LineLength: 20 | Max: 120 21 | 22 | Rails: 23 | Enabled: true 24 | 25 | Style/Documentation: 26 | Enabled: false 27 | 28 | Style/ClassAndModuleChildren: 29 | Enabled: false 30 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.0 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '3.1.0' 7 | 8 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 9 | gem 'rails', '~> 7.0.2', '>= 7.0.2.3' 10 | 11 | # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] 12 | gem 'sprockets-rails' 13 | 14 | # Use sqlite3 as the database for Active Record 15 | gem 'sqlite3', '~> 1.4' 16 | 17 | # Use the Puma web server [https://github.com/puma/puma] 18 | gem 'puma', '~> 5.0' 19 | 20 | # Bundle and transpile JavaScript [https://github.com/rails/jsbundling-rails] 21 | gem 'jsbundling-rails' 22 | 23 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 24 | gem 'turbo-rails' 25 | 26 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 27 | gem 'stimulus-rails' 28 | 29 | # Build JSON APIs with ease [https://github.com/rails/jbuilder] 30 | gem 'jbuilder' 31 | 32 | # Use Redis adapter to run Action Cable in production 33 | # gem "redis", "~> 4.0" 34 | 35 | # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] 36 | # gem "kredis" 37 | 38 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] 39 | # gem "bcrypt", "~> 3.1.7" 40 | 41 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 42 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 43 | 44 | # Reduces boot times through caching; required in config/boot.rb 45 | gem 'bootsnap', require: false 46 | 47 | # Use Sass to process CSS 48 | # gem "sassc-rails" 49 | 50 | # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] 51 | # gem "image_processing", "~> 1.2" 52 | 53 | group :development, :test do 54 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 55 | gem 'debug', platforms: %i[mri mingw x64_mingw] 56 | end 57 | 58 | group :development do 59 | # Use console on exceptions pages [https://github.com/rails/web-console] 60 | gem 'web-console' 61 | 62 | # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] 63 | # gem "rack-mini-profiler" 64 | 65 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring] 66 | # gem "spring" 67 | end 68 | 69 | group :test do 70 | # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] 71 | gem 'capybara' 72 | gem 'selenium-webdriver' 73 | gem 'webdrivers' 74 | end 75 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (7.0.2.3) 5 | actionpack (= 7.0.2.3) 6 | activesupport (= 7.0.2.3) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailbox (7.0.2.3) 10 | actionpack (= 7.0.2.3) 11 | activejob (= 7.0.2.3) 12 | activerecord (= 7.0.2.3) 13 | activestorage (= 7.0.2.3) 14 | activesupport (= 7.0.2.3) 15 | mail (>= 2.7.1) 16 | net-imap 17 | net-pop 18 | net-smtp 19 | actionmailer (7.0.2.3) 20 | actionpack (= 7.0.2.3) 21 | actionview (= 7.0.2.3) 22 | activejob (= 7.0.2.3) 23 | activesupport (= 7.0.2.3) 24 | mail (~> 2.5, >= 2.5.4) 25 | net-imap 26 | net-pop 27 | net-smtp 28 | rails-dom-testing (~> 2.0) 29 | actionpack (7.0.2.3) 30 | actionview (= 7.0.2.3) 31 | activesupport (= 7.0.2.3) 32 | rack (~> 2.0, >= 2.2.0) 33 | rack-test (>= 0.6.3) 34 | rails-dom-testing (~> 2.0) 35 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 36 | actiontext (7.0.2.3) 37 | actionpack (= 7.0.2.3) 38 | activerecord (= 7.0.2.3) 39 | activestorage (= 7.0.2.3) 40 | activesupport (= 7.0.2.3) 41 | globalid (>= 0.6.0) 42 | nokogiri (>= 1.8.5) 43 | actionview (7.0.2.3) 44 | activesupport (= 7.0.2.3) 45 | builder (~> 3.1) 46 | erubi (~> 1.4) 47 | rails-dom-testing (~> 2.0) 48 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 49 | activejob (7.0.2.3) 50 | activesupport (= 7.0.2.3) 51 | globalid (>= 0.3.6) 52 | activemodel (7.0.2.3) 53 | activesupport (= 7.0.2.3) 54 | activerecord (7.0.2.3) 55 | activemodel (= 7.0.2.3) 56 | activesupport (= 7.0.2.3) 57 | activestorage (7.0.2.3) 58 | actionpack (= 7.0.2.3) 59 | activejob (= 7.0.2.3) 60 | activerecord (= 7.0.2.3) 61 | activesupport (= 7.0.2.3) 62 | marcel (~> 1.0) 63 | mini_mime (>= 1.1.0) 64 | activesupport (7.0.2.3) 65 | concurrent-ruby (~> 1.0, >= 1.0.2) 66 | i18n (>= 1.6, < 2) 67 | minitest (>= 5.1) 68 | tzinfo (~> 2.0) 69 | addressable (2.8.0) 70 | public_suffix (>= 2.0.2, < 5.0) 71 | bindex (0.8.1) 72 | bootsnap (1.11.1) 73 | msgpack (~> 1.2) 74 | builder (3.2.4) 75 | capybara (3.36.0) 76 | addressable 77 | matrix 78 | mini_mime (>= 0.1.3) 79 | nokogiri (~> 1.8) 80 | rack (>= 1.6.0) 81 | rack-test (>= 0.6.3) 82 | regexp_parser (>= 1.5, < 3.0) 83 | xpath (~> 3.2) 84 | childprocess (4.1.0) 85 | concurrent-ruby (1.1.9) 86 | crass (1.0.6) 87 | debug (1.4.0) 88 | irb (>= 1.3.6) 89 | reline (>= 0.2.7) 90 | digest (3.1.0) 91 | erubi (1.10.0) 92 | globalid (1.0.0) 93 | activesupport (>= 5.0) 94 | i18n (1.10.0) 95 | concurrent-ruby (~> 1.0) 96 | io-console (0.5.11) 97 | io-wait (0.2.1) 98 | irb (1.4.1) 99 | reline (>= 0.3.0) 100 | jbuilder (2.11.5) 101 | actionview (>= 5.0.0) 102 | activesupport (>= 5.0.0) 103 | jsbundling-rails (1.0.2) 104 | railties (>= 6.0.0) 105 | loofah (2.14.0) 106 | crass (~> 1.0.2) 107 | nokogiri (>= 1.5.9) 108 | mail (2.7.1) 109 | mini_mime (>= 0.1.1) 110 | marcel (1.0.2) 111 | matrix (0.4.2) 112 | method_source (1.0.0) 113 | mini_mime (1.1.2) 114 | minitest (5.15.0) 115 | msgpack (1.4.5) 116 | net-imap (0.2.3) 117 | digest 118 | net-protocol 119 | strscan 120 | net-pop (0.1.1) 121 | digest 122 | net-protocol 123 | timeout 124 | net-protocol (0.1.2) 125 | io-wait 126 | timeout 127 | net-smtp (0.3.1) 128 | digest 129 | net-protocol 130 | timeout 131 | nio4r (2.5.8) 132 | nokogiri (1.13.3-x86_64-linux) 133 | racc (~> 1.4) 134 | public_suffix (4.0.6) 135 | puma (5.6.2) 136 | nio4r (~> 2.0) 137 | racc (1.6.0) 138 | rack (2.2.3) 139 | rack-test (1.1.0) 140 | rack (>= 1.0, < 3) 141 | rails (7.0.2.3) 142 | actioncable (= 7.0.2.3) 143 | actionmailbox (= 7.0.2.3) 144 | actionmailer (= 7.0.2.3) 145 | actionpack (= 7.0.2.3) 146 | actiontext (= 7.0.2.3) 147 | actionview (= 7.0.2.3) 148 | activejob (= 7.0.2.3) 149 | activemodel (= 7.0.2.3) 150 | activerecord (= 7.0.2.3) 151 | activestorage (= 7.0.2.3) 152 | activesupport (= 7.0.2.3) 153 | bundler (>= 1.15.0) 154 | railties (= 7.0.2.3) 155 | rails-dom-testing (2.0.3) 156 | activesupport (>= 4.2.0) 157 | nokogiri (>= 1.6) 158 | rails-html-sanitizer (1.4.2) 159 | loofah (~> 2.3) 160 | railties (7.0.2.3) 161 | actionpack (= 7.0.2.3) 162 | activesupport (= 7.0.2.3) 163 | method_source 164 | rake (>= 12.2) 165 | thor (~> 1.0) 166 | zeitwerk (~> 2.5) 167 | rake (13.0.6) 168 | regexp_parser (2.2.1) 169 | reline (0.3.1) 170 | io-console (~> 0.5) 171 | rexml (3.2.5) 172 | rubyzip (2.3.2) 173 | selenium-webdriver (4.1.0) 174 | childprocess (>= 0.5, < 5.0) 175 | rexml (~> 3.2, >= 3.2.5) 176 | rubyzip (>= 1.2.2) 177 | sprockets (4.0.3) 178 | concurrent-ruby (~> 1.0) 179 | rack (> 1, < 3) 180 | sprockets-rails (3.4.2) 181 | actionpack (>= 5.2) 182 | activesupport (>= 5.2) 183 | sprockets (>= 3.0.0) 184 | sqlite3 (1.4.2) 185 | stimulus-rails (1.0.4) 186 | railties (>= 6.0.0) 187 | strscan (3.0.1) 188 | thor (1.2.1) 189 | timeout (0.2.0) 190 | turbo-rails (1.0.1) 191 | actionpack (>= 6.0.0) 192 | railties (>= 6.0.0) 193 | tzinfo (2.0.4) 194 | concurrent-ruby (~> 1.0) 195 | web-console (4.2.0) 196 | actionview (>= 6.0.0) 197 | activemodel (>= 6.0.0) 198 | bindex (>= 0.4.0) 199 | railties (>= 6.0.0) 200 | webdrivers (5.0.0) 201 | nokogiri (~> 1.6) 202 | rubyzip (>= 1.3.0) 203 | selenium-webdriver (~> 4.0) 204 | websocket-driver (0.7.5) 205 | websocket-extensions (>= 0.1.0) 206 | websocket-extensions (0.1.5) 207 | xpath (3.2.0) 208 | nokogiri (~> 1.8) 209 | zeitwerk (2.5.4) 210 | 211 | PLATFORMS 212 | x86_64-linux 213 | 214 | DEPENDENCIES 215 | bootsnap 216 | capybara 217 | debug 218 | jbuilder 219 | jsbundling-rails 220 | puma (~> 5.0) 221 | rails (~> 7.0.2, >= 7.0.2.3) 222 | selenium-webdriver 223 | sprockets-rails 224 | sqlite3 (~> 1.4) 225 | stimulus-rails 226 | turbo-rails 227 | tzinfo-data 228 | web-console 229 | webdrivers 230 | 231 | RUBY VERSION 232 | ruby 3.1.0p0 233 | 234 | BUNDLED WITH 235 | 2.3.3 236 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 2 | js: npm run watch 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a Simple CRUD App with Rails and React 2 | 3 | This is the code repository to accompany a tutorial on how to create a Rails API then, using esbuild, build a React front-end to consume it. 4 | 5 | Tutorial URL: [https://hibbard.eu/rails-react-crud-app/](https://hibbard.eu/rails-react-crud-app/) 6 | 7 | **This is the code for the updated version of the tutorial. You can find the code for the older version on the [classes branch](https://github.com/jameshibbard/react-rails-crud-app/tree/classes)** 8 | 9 | ## Requirements 10 | 11 | - [Ruby](https://www.ruby-lang.org/en/downloads/) 12 | - [Node.js](http://nodejs.org/) 13 | 14 | There are instructions for installing both Ruby and Node at the beginning of the tutorial. 15 | 16 | ## Installation 17 | 18 | - Clone repo 19 | - Run `bundle install` 20 | - Run `npm install` 21 | - Run `rake db:create`, `rake db:migrate`, then `rake db:seed` 22 | 23 | ## Running 24 | 25 | - Start the Rails server and esbuild with one command `./bin/dev` 26 | - Hit http://localhost:3000/events/ 27 | 28 | ## License 29 | 30 | Code archives and code examples are licensed under the MIT license. 31 | 32 | Copyright © 2022 James Hibbard 33 | 34 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 35 | 36 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 37 | 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 39 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshibbard/react-rails-crud-app/e6b0f9596a6e590f9489272154972e1395ffffc9/app/assets/builds/.keep -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../builds 4 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshibbard/react-rails-crud-app/e6b0f9596a6e590f9489272154972e1395ffffc9/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshibbard/react-rails-crud-app/e6b0f9596a6e590f9489272154972e1395ffffc9/app/assets/stylesheets/.keep -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/api/events_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Api::EventsController < ApplicationController 4 | before_action :set_event, only: %i[show update destroy] 5 | 6 | def index 7 | @events = Event.all 8 | render json: @events 9 | end 10 | 11 | def show 12 | render json: @event 13 | end 14 | 15 | def create 16 | @event = Event.new(event_params) 17 | 18 | if @event.save 19 | render json: @event, status: :created 20 | else 21 | render json: @event.errors, status: :unprocessable_entity 22 | end 23 | end 24 | 25 | def update 26 | if @event.update(event_params) 27 | render json: @event, status: :ok 28 | else 29 | render json: @event.errors, status: :unprocessable_entity 30 | end 31 | end 32 | 33 | def destroy 34 | @event.destroy 35 | end 36 | 37 | private 38 | 39 | def set_event 40 | @event = Event.find(params[:id]) 41 | end 42 | 43 | def event_params 44 | params.require(:event).permit( 45 | :id, :event_type, :event_date, :title, :speaker, :host, :published, :created_at, :updated_at 46 | ) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | protect_from_forgery with: :null_session 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameshibbard/react-rails-crud-app/e6b0f9596a6e590f9489272154972e1395ffffc9/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/site_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SiteController < ApplicationController 4 | def index; end 5 | end 6 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /app/helpers/site_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SiteHelper 4 | end 5 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | // Entry point for the build script in your package.json 4 | import '@hotwired/turbo-rails'; 5 | import './controllers'; 6 | 7 | import React, { StrictMode } from 'react'; 8 | import { createRoot } from 'react-dom/client'; 9 | import { BrowserRouter } from 'react-router-dom'; 10 | import App from './components/App'; 11 | 12 | const container = document.getElementById('root'); 13 | const root = createRoot(container); 14 | 15 | document.addEventListener('DOMContentLoaded', () => { 16 | root.render( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /app/javascript/components/App.css: -------------------------------------------------------------------------------- 1 | body, html, div, blockquote, img, label, p, h1, h2, h3, h4, h5, h6, pre, ul, ol, li, dl, dt, dd, form, a, fieldset, input, th, td { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | ul, ol { 7 | list-style: none; 8 | } 9 | 10 | body { 11 | font-family: Roboto; 12 | font-size: 16px; 13 | line-height: 28px; 14 | } 15 | 16 | header { 17 | background: #f57011; 18 | height: 60px; 19 | } 20 | 21 | header h1, header h1 a{ 22 | display: inline-block; 23 | font-family: "Maven Pro"; 24 | font-size: 28px; 25 | font-weight: 500; 26 | color: white; 27 | padding: 14px 5%; 28 | text-decoration: none; 29 | } 30 | 31 | header h1:hover { 32 | text-decoration: underline; 33 | } 34 | 35 | .grid { 36 | display: grid; 37 | grid-gap: 50px; 38 | grid-template-columns: minmax(250px, 20%) auto; 39 | margin: 25px auto; 40 | width: 90%; 41 | height: calc(100vh - 145px); 42 | } 43 | 44 | .eventList { 45 | background: #f6f6f6; 46 | padding: 16px; 47 | } 48 | 49 | .eventList h2 { 50 | font-size: 20px; 51 | padding: 8px 6px 10px; 52 | } 53 | 54 | .eventContainer { 55 | font-size: 15px; 56 | line-height: 35px; 57 | } 58 | 59 | .eventContainer h2 { 60 | margin-bottom: 10px; 61 | } 62 | 63 | .eventList li:hover, a.active { 64 | background: #f8e5ce; 65 | } 66 | 67 | .eventList a { 68 | display: block; 69 | color: black; 70 | text-decoration: none; 71 | border-bottom: 1px solid #dddddd; 72 | padding: 8px 6px 10px; 73 | } 74 | 75 | .eventList h2 > a { 76 | color: #236fff; 77 | font-size: 15px; 78 | float: right; 79 | font-weight: normal; 80 | border-bottom: none; 81 | padding: 0px; 82 | } 83 | 84 | .eventForm { 85 | margin-top: 15px; 86 | } 87 | 88 | label > strong { 89 | display: inline-block; 90 | vertical-align: top; 91 | text-align: right; 92 | width: 100px; 93 | margin-right: 6px; 94 | font-size: 15px; 95 | } 96 | 97 | input, textarea { 98 | padding: 2px 0 3px 3px; 99 | width: 400px; 100 | margin-bottom: 15px; 101 | box-sizing: border-box; 102 | } 103 | 104 | input[type="checkbox"] { 105 | width: 13px; 106 | } 107 | 108 | button[type="submit"] { 109 | background: #f57011; 110 | border: none; 111 | padding: 5px 25px 8px; 112 | font-weight: 500; 113 | color: white; 114 | cursor: pointer; 115 | margin: 10px 0 0 106px; 116 | } 117 | 118 | .errors { 119 | border: 1px solid red; 120 | border-radius: 5px; 121 | margin: 20px 0 35px 0; 122 | width: 513px; 123 | } 124 | 125 | .errors h3 { 126 | background: red; 127 | color: white; 128 | padding: 10px; 129 | font-size: 15px; 130 | } 131 | 132 | .errors ul li { 133 | list-style-type: none; 134 | margin: 0; 135 | padding: 8px 0 8px 10px; 136 | border-top: solid 1px pink; 137 | font-size: 12px; 138 | font-weight: 0.9; 139 | } 140 | 141 | button.delete { 142 | background: none !important; 143 | border: none; 144 | padding: 0 !important; 145 | margin-left: 10px; 146 | cursor: pointer; 147 | color: #236fff; 148 | font-size: 15px; 149 | font-weight: normal; 150 | text-decoration: none; 151 | } 152 | 153 | button.delete:hover { 154 | text-decoration: underline; 155 | } 156 | 157 | h2 a { 158 | color: #236fff; 159 | font-size: 15px; 160 | font-weight: normal; 161 | margin: 3px 12px 0 12px; 162 | text-decoration: none; 163 | } 164 | 165 | h2 a:hover { 166 | text-decoration: underline; 167 | } 168 | 169 | .form-actions a { 170 | color: #236fff; 171 | font-size: 15px; 172 | margin: 3px 12px 0 12px; 173 | text-decoration: none; 174 | } 175 | 176 | .form-actions a:hover { 177 | text-decoration: underline; 178 | } 179 | 180 | input.search { 181 | width: 92%; 182 | margin: 15px 2px; 183 | padding: 4px 0 6px 6px; 184 | } 185 | 186 | .loading { 187 | height: calc(100vh - 60px); 188 | display: grid; 189 | justify-content: center; 190 | align-content: center; 191 | } 192 | -------------------------------------------------------------------------------- /app/javascript/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import { ToastContainer } from 'react-toastify'; 4 | import Editor from './Editor'; 5 | import './App.css'; 6 | 7 | const App = () => ( 8 | <> 9 | 10 | } /> 11 | 12 | 13 | 14 | ); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /app/javascript/components/Editor.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | import { Routes, Route, useNavigate } from 'react-router-dom'; 5 | import Header from './Header'; 6 | import Event from './Event'; 7 | import EventForm from './EventForm'; 8 | import EventList from './EventList'; 9 | import { success } from '../helpers/notifications'; 10 | import { handleAjaxError } from '../helpers/helpers'; 11 | 12 | const Editor = () => { 13 | const [events, setEvents] = useState([]); 14 | const [isLoading, setIsLoading] = useState(true); 15 | const navigate = useNavigate(); 16 | 17 | useEffect(() => { 18 | const fetchData = async () => { 19 | try { 20 | const response = await window.fetch('/api/events.json'); 21 | if (!response.ok) throw Error(response.statusText); 22 | 23 | const data = await response.json(); 24 | setEvents(data); 25 | } catch (error) { 26 | handleAjaxError(error); 27 | } 28 | 29 | setIsLoading(false); 30 | }; 31 | 32 | fetchData(); 33 | }, []); 34 | 35 | const addEvent = async (newEvent) => { 36 | try { 37 | const response = await window.fetch('/api/events.json', { 38 | method: 'POST', 39 | body: JSON.stringify(newEvent), 40 | headers: { 41 | Accept: 'application/json', 42 | 'Content-Type': 'application/json', 43 | }, 44 | }); 45 | 46 | if (!response.ok) throw Error(response.statusText); 47 | 48 | const savedEvent = await response.json(); 49 | const newEvents = [...events, savedEvent]; 50 | setEvents(newEvents); 51 | success('Event Added!'); 52 | navigate(`/events/${savedEvent.id}`); 53 | } catch (error) { 54 | handleAjaxError(error); 55 | } 56 | }; 57 | 58 | const deleteEvent = async (eventId) => { 59 | const sure = window.confirm('Are you sure?'); 60 | 61 | if (sure) { 62 | try { 63 | const response = await window.fetch(`/api/events/${eventId}.json`, { 64 | method: 'DELETE', 65 | }); 66 | 67 | if (!response.ok) throw Error(response.statusText); 68 | 69 | success('Event Deleted!'); 70 | navigate('/events'); 71 | setEvents(events.filter(event => event.id !== eventId)); 72 | } catch (error) { 73 | handleAjaxError(error); 74 | } 75 | } 76 | }; 77 | 78 | const updateEvent = async (updatedEvent) => { 79 | try { 80 | const response = await window.fetch( 81 | `/api/events/${updatedEvent.id}.json`, 82 | { 83 | method: 'PUT', 84 | body: JSON.stringify(updatedEvent), 85 | headers: { 86 | Accept: 'application/json', 87 | 'Content-Type': 'application/json', 88 | }, 89 | } 90 | ); 91 | 92 | if (!response.ok) throw Error(response.statusText); 93 | 94 | const newEvents = events; 95 | const idx = newEvents.findIndex((event) => event.id === updatedEvent.id); 96 | newEvents[idx] = updatedEvent; 97 | setEvents(newEvents); 98 | 99 | success('Event Updated!'); 100 | navigate(`/events/${updatedEvent.id}`); 101 | } catch (error) { 102 | handleAjaxError(error); 103 | } 104 | }; 105 | 106 | return ( 107 | <> 108 |
109 | {isLoading ? ( 110 |

Loading...

111 | ) : ( 112 |
113 | 114 | 115 | 116 | } 119 | /> 120 | } 123 | /> 124 | } /> 125 | 126 |
127 | )} 128 | 129 | ); 130 | }; 131 | 132 | export default Editor; 133 | -------------------------------------------------------------------------------- /app/javascript/components/Event.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useParams, Link } from 'react-router-dom'; 4 | import EventNotFound from './EventNotFound'; 5 | 6 | const Event = ({ events, onDelete }) => { 7 | const { id } = useParams(); 8 | const event = events.find((e) => e.id === Number(id)); 9 | 10 | if (!event) return ; 11 | 12 | return ( 13 |
14 |

15 | {event.event_date} 16 | {' - '} 17 | {event.event_type} 18 | Edit 19 | 26 |

27 |
    28 |
  • 29 | Type: {event.event_type} 30 |
  • 31 |
  • 32 | Date: {event.event_date} 33 |
  • 34 |
  • 35 | Title: {event.title} 36 |
  • 37 |
  • 38 | Speaker: {event.speaker} 39 |
  • 40 |
  • 41 | Host: {event.host} 42 |
  • 43 |
  • 44 | Published: {event.published ? 'yes' : 'no'} 45 |
  • 46 |
47 |
48 | ); 49 | }; 50 | 51 | Event.propTypes = { 52 | events: PropTypes.arrayOf( 53 | PropTypes.shape({ 54 | id: PropTypes.number.isRequired, 55 | event_type: PropTypes.string.isRequired, 56 | event_date: PropTypes.string.isRequired, 57 | title: PropTypes.string.isRequired, 58 | speaker: PropTypes.string.isRequired, 59 | host: PropTypes.string.isRequired, 60 | published: PropTypes.bool.isRequired, 61 | }) 62 | ).isRequired, 63 | onDelete: PropTypes.func.isRequired, 64 | }; 65 | 66 | export default Event; 67 | -------------------------------------------------------------------------------- /app/javascript/components/EventForm.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState, useRef } from 'react'; 2 | import { useParams, Link } from 'react-router-dom'; 3 | import Pikaday from 'pikaday'; 4 | import PropTypes from 'prop-types'; 5 | import EventNotFound from './EventNotFound'; 6 | import { formatDate, isEmptyObject, validateEvent } from '../helpers/helpers'; 7 | 8 | import 'pikaday/css/pikaday.css'; 9 | 10 | const EventForm = ({ events, onSave }) => { 11 | const { id } = useParams(); 12 | 13 | const initialEventState = useCallback( 14 | () => { 15 | const defaults = { 16 | event_type: '', 17 | event_date: '', 18 | title: '', 19 | speaker: '', 20 | host: '', 21 | published: false, 22 | }; 23 | const currEvent = id ? events.find((e) => e.id === Number(id)) : {}; 24 | return { ...defaults, ...currEvent } 25 | }, 26 | [events, id] 27 | ); 28 | 29 | const [event, setEvent] = useState(initialEventState); 30 | const [formErrors, setFormErrors] = useState({}); 31 | const dateInput = useRef(null); 32 | 33 | const updateEvent = (key, value) => { 34 | setEvent((prevEvent) => ({ ...prevEvent, [key]: value })); 35 | }; 36 | 37 | useEffect(() => { 38 | const p = new Pikaday({ 39 | field: dateInput.current, 40 | toString: date => formatDate(date), 41 | onSelect: (date) => { 42 | const formattedDate = formatDate(date); 43 | dateInput.current.value = formattedDate; 44 | updateEvent('event_date', formattedDate); 45 | }, 46 | }); 47 | 48 | // Return a cleanup function. 49 | // React will call this prior to unmounting. 50 | return () => p.destroy(); 51 | }, []); 52 | 53 | const handleInputChange = (e) => { 54 | const { target } = e; 55 | const { name } = target; 56 | const value = target.type === 'checkbox' ? target.checked : target.value; 57 | 58 | updateEvent(name, value); 59 | }; 60 | 61 | useEffect(() => { 62 | setEvent(initialEventState); 63 | }, [events, initialEventState]); 64 | 65 | const renderErrors = () => { 66 | if (isEmptyObject(formErrors)) return null; 67 | 68 | return ( 69 |
70 |

The following errors prohibited the event from being saved:

71 |
    72 | {Object.values(formErrors).map((formError) => ( 73 |
  • {formError}
  • 74 | ))} 75 |
76 |
77 | ); 78 | }; 79 | 80 | const handleSubmit = (e) => { 81 | e.preventDefault(); 82 | const errors = validateEvent(event); 83 | 84 | if (!isEmptyObject(errors)) { 85 | setFormErrors(errors); 86 | } else { 87 | onSave(event); 88 | } 89 | }; 90 | 91 | const cancelURL = event.id ? `/events/${event.id}` : '/events'; 92 | const title = event.id ? `${event.event_date} - ${event.event_type}` : 'New Event'; 93 | 94 | if (id && !event.id) return ; 95 | 96 | return ( 97 |
98 |

{title}

99 | {renderErrors()} 100 | 101 |
102 |
103 | 113 |
114 |
115 | 127 |
128 |
129 |