Ubuntu 20.04 での Ruby on Rails v7 プロジェクトと React フロントエンドのセットアップ方法

著者は、プログラムの一環として寄付を受け取る団体として、Electronic Frontier Foundationを選択しました。

はじめに

Ruby on Railsは人気のあるサーバーサイドのWebアプリケーションフレームワークです。これは、GitHubBasecampSoundCloudAirbnb、およびTwitchなど、現在ウェブ上に存在する多くの人気のあるアプリケーションを動かしています。プログラマーの経験とそれを取り巻く情熱的なコミュニティに重点を置いており、Ruby on Railsは、現代のWebアプリケーションを構築および維持するために必要なツールを提供します。

Reactは、フロントエンドのユーザーインターフェイスを作成するために使用されるJavaScriptライブラリです。Facebookに支持されており、現在ウェブ上で最も人気のあるフロントエンドライブラリの1つです。Reactには、仮想DOM(virtual Document Object Model (DOM))、コンポーネントアーキテクチャ、および状態管理などの機能があり、フロントエンド開発のプロセスをより整理されて効率的にします。

Webのフロントエンドがサーバーサイドのコードとは別のフレームワークに移行する中で、RailsのエレガンスとReactの効率を組み合わせることで、最新のトレンドに基づいた強力でモダンなアプリケーションを構築することができます。Railsのテンプレートエンジンではなく、Reactを使用してコンポーネントをRailsのビューからレンダリングすることで、JavaScriptとフロントエンド開発の最新の進歩を活用しながら、Ruby on Railsの表現力を活かすことができます。

このチュートリアルでは、お気に入りのレシピを保存し、Reactのフロントエンドで表示するRuby on Railsアプリケーションを作成します。作業が完了すると、Bootstrapでスタイル付けされたReactインターフェースを使用して、レシピを作成、表示、削除することができるようになります。

前提条件

このチュートリアルを進めるには、以下が必要です:

  • Node.jsおよびnpmが開発マシンにインストールされています。このチュートリアルでは、Node.jsバージョン16.14.0とnpmバージョン8.3.1を使用しています。Node.jsは、ブラウザの外でコードを実行することができるJavaScriptランタイム環境です。Node.jsには、npmと呼ばれる事前にインストールされたパッケージマネージャが付属しており、パッケージのインストールや更新を行うことができます。これらをUbuntu 20.04またはmacOSにインストールするには、Ubuntu 20.04にNode.jsをインストールする方法の「PPAを使用してインストールする」セクションに従うか、macOSにNode.jsをインストールしてローカル開発環境を作成する方法の手順に従ってください。

  • 開発マシンにはYarnパッケージマネージャがインストールされており、これによりReactフレームワークをダウンロードできます。このチュートリアルはバージョン1.22.10でテストされました。この依存関係をインストールするには、公式のYarnインストールガイドに従ってください。

  • Ruby on Railsがインストールされました。これを取得するには、Ubuntu 20.04でrbenvを使用してRuby on Railsをインストールする方法ガイドに従ってください。このアプリケーションをmacOSで開発したい場合は、macOSでrbenvを使用してRuby on Railsをインストールする方法をご利用いただけます。このチュートリアルはRubyのバージョン3.1.2とRailsのバージョン7.0.4でテストされましたので、インストールプロセス中にこれらのバージョンを指定してください。

注意: Railsのバージョン7は後方互換性がありません。Railsのバージョン5を使用している場合は、Ubuntu 18.04でReactフロントエンド付きのRuby on Rails v5プロジェクトを設定する方法のチュートリアルを参照してください。

ステップ1 — 新しいRailsアプリケーションの作成

このステップでは、Railsアプリケーションフレームワークでレシピアプリケーションを構築します。まず、Reactと連携する新しいRailsアプリケーションを作成します。

Railsは、モダンなWebアプリケーションを構築するために必要なすべてを作成するジェネレーターと呼ばれるいくつかのスクリプトを提供しています。これらのコマンドとそれらが行うことの完全なリストを確認するには、ターミナルで次のコマンドを実行してください:

  1. rails -h

このコマンドは、アプリケーションのパラメータを設定するための包括的なオプションリストを生成します。リストされているコマンドの1つは、newコマンドで、これにより新しいRailsアプリケーションが作成されます。

次に、newジェネレータを使用して新しいRailsアプリケーションを作成します。ターミナルで次のコマンドを実行します:

  1. rails new rails_react_recipe -d postgresql -j esbuild -c bootstrap -T

前述のコマンドは、rails_react_recipeというディレクトリに新しいRailsアプリケーションを作成し、必要なRubyおよびJavaScriptの依存関係をインストールし、Webpackを構成します。このnewジェネレータコマンドに関連付けられているフラグには、次のものがあります:

  • -dフラグは、この場合にはPostgreSQLが好ましいデータベースエンジンを指定します。
  • -jフラグは、アプリケーションのJavaScriptアプローチを指定します。Railsは、Railsアプリケーション内のJavaScriptコードを処理するいくつかの異なる方法を提供しています。-jフラグに渡されるesbuildオプションは、-jフラグにesbuildを優先するJavaScriptバンドラとして事前に設定するようRailsに指示します。
  • -cフラグは、アプリケーションのCSSプロセッサを指定します。この場合、Bootstrapが優先されます。
  • -Tフラグは、このチュートリアルでテストを書かないため、テストファイルの生成をスキップするようRailsに指示します。このコマンドは、Railsが提供するテストツールとは異なるRubyのテストツールを使用したい場合にも推奨されます。

コマンドが完了したら、アプリのルートディレクトリであるrails_react_recipeディレクトリに移動します:

  1. cd rails_react_recipe

次に、ディレクトリの内容をリストします:

  1. ls

Output
Gemfile README.md bin db node_modules storage yarn.lock Gemfile.lock Rakefile config lib package.json tmp Procfile.dev app config.ru log public vendor

このルートディレクトリには、Railsアプリケーションの構造を構成するいくつかの自動生成されたファイルとフォルダがあります。これには、Reactアプリケーションの依存関係を含むpackage.jsonファイルが含まれています。

新しいRailsアプリケーションを正常に作成したので、次のステップではデータベースに接続します。

ステップ2 — データベースの設定

新しいRailsアプリケーションを実行する前に、まずデータベースに接続する必要があります。このステップでは、新しく作成したRailsアプリケーションをPostgreSQLデータベースに接続し、レシピデータを必要に応じて保存および取得します。

config/database.ymlにあるdatabase.ymlファイルには、異なる開発環境のデータベース名などのデータベースの詳細が含まれています。Railsは、各開発環境にデータベース名を環境名の後にアンダースコア(_)を付けて指定します。このチュートリアルでは、デフォルトのデータベース構成値を使用しますが、必要に応じて構成値を変更できます。

注意:この時点で、Railsがデータベースを作成するために使用するPostgreSQLのロールを設定するためにconfig/database.ymlを変更することができます。事前条件として、パスワードで保護されたロールを作成しましたRuby on RailsアプリケーションでPostgreSQLを使用する方法チュートリアルで。まだユーザーを設定していない場合は、同じ事前条件チュートリアルのステップ4 — データベースの設定と作成の指示に従うことができます。

Railsは、createdropresetなどのデータベース作業に役立つ多くのコマンドを提供しています。アプリケーションのデータベースを作成するには、ターミナルで以下のコマンドを実行します:

  1. rails db:create

このコマンドはdevelopmenttestのデータベースを作成し、次の出力を生成します:

Output
Created database 'rails_react_recipe_development' Created database 'rails_react_recipe_test'

アプリケーションがデータベースに接続されたので、次のコマンドを実行してアプリケーションを起動します:

  1. bin/dev

Railsはbin/devスクリプトの代替手段を提供しており、アプリのルートディレクトリにあるProcfile.devファイルのコマンドをForemanジェムを使用して実行することにより、Railsアプリケーションを起動します。

このコマンドを実行すると、コマンドプロンプトが消え、その代わりに次の出力が印刷されます:

Output
started with pid 70099 started with pid 70100 started with pid 70101 yarn run v1.22.10 yarn run v1.22.10 $ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets --watch $ sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules --watch => Booting Puma => Rails 7.0.4 application starting in development => Run `bin/rails server --help` for more startup options [watch] build finished, watching for changes... Puma starting in single mode... * Puma version: 5.6.5 (ruby 3.1.2-p20) ("Birdie's Version") * Min threads: 5 * Max threads: 5 * Environment: development * PID: 70099 * Listening on http://127.0.0.1:3000 * Listening on http://[::1]:3000 Use Ctrl-C to stop Sass is watching for changes. Press Ctrl-C to stop.

アプリケーションにアクセスするには、ブラウザウィンドウを開いてhttp://localhost:3000に移動します。Railsのデフォルトのウェルカムページが読み込まれます。これは、Railsアプリケーションを正しく設定したことを意味します:

ウェブサーバーを停止するには、サーバーが実行されている端末でCTRL+Cを押します。Pumaからさようならメッセージが表示されます:

Output
^C SIGINT received, starting shutdown - Gracefully stopping, waiting for requests to finish === puma shutdown: 2019-07-31 14:21:24 -0400 === - Goodbye! Exiting sending SIGTERM to all processes terminated by SIGINT terminated by SIGINT exited with code 0

その後、ターミナルプロンプトが再表示されます。

料理レシピアプリケーションのデータベースの設定が成功しました。次のステップでは、Reactフロントエンドを構築するために必要なJavaScriptの依存関係をインストールします。

ステップ3 — フロントエンドの依存関係のインストール

このステップでは、料理レシピアプリケーションのフロントエンドで必要なJavaScriptの依存関係をインストールします。それらには以下が含まれます:

これらのパッケージをYarnパッケージマネージャーでインストールするには、次のコマンドを実行します:

  1. yarn add react react-dom react-router-dom

このコマンドは、指定されたパッケージをインストールし、それらをpackage.jsonファイルに追加するためにYarnを使用します。これを確認するには、プロジェクトのルートディレクトリにあるpackage.jsonファイルを開きます:

  1. nano package.json

インストールされたパッケージはdependenciesキーの下にリストされます:

~/rails_react_recipe/package.json
{
  "name": "app",
  "private": "true",
  "dependencies": {
    "@hotwired/stimulus": "^3.1.0",
    "@hotwired/turbo-rails": "^7.1.3",
    "@popperjs/core": "^2.11.6",
    "bootstrap": "^5.2.1",
    "bootstrap-icons": "^1.9.1",
    "esbuild": "^0.15.7",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.3.0",
    "sass": "^1.54.9"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
    "build:css": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
  }
}

ファイルをCTRL+Xで閉じます。

いくつかのフロントエンド依存関係をアプリケーションにインストールしました。次に、食事レシピアプリケーションのホームページを設定します。

ステップ4 — ホームページの設定

必要な依存関係がインストールされたので、アプリケーションのホームページを作成し、ユーザーがアプリケーションを最初に訪れたときのランディングページとして機能させます。

RailsはアプリケーションのためにModel-View-Controllerアーキテクチャパターンに従います。MVCパターンでは、コントローラーの目的は特定のリクエストを受け取り、それらを適切なモデルまたはビューに渡すことです。現在、ルートURLがブラウザでロードされると、アプリケーションはRailsのウェルカムページを表示します。これを変更するには、ホームページのためのコントローラーとビューを作成し、それをルートにマッチさせます。

Railsは、コントローラを作成するためのcontrollerジェネレータを提供します。このcontrollerジェネレータは、コントローラ名と対応するアクションを受け取ります。これについて詳しくは、Railsのドキュメントを参照してください。

このチュートリアルでは、コントローラをHomepageと呼びます。次のコマンドを実行して、Homepageコントローラとindexアクションを作成します:

  1. rails g controller Homepage index

注意:
Linuxでは、エラーFATAL: Listen error: unable to monitor directories for changes.が、システムの制限により、マシンが監視できるファイルの数を超えた場合に発生する場合があります。これを修正するには、次のコマンドを実行します:

  1. echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p

このコマンドは、Listenで監視できるディレクトリの数を524288に永久的に増やします。再度同じコマンドを実行し、524288をご希望の数値に置き換えることで、これを変更できます。

controllerコマンドを実行すると、次のファイルが生成されます:

  • A homepage_controller.rb file for receiving all homepage-related requests. This file contains the index action you specified in the command.
  • A homepage_helper.rb file for adding helper methods related to the Homepage controller.
  • ホームページに関連するものをレンダリングするためのindex.html.erbファイル。

Railsコマンドを実行して作成されたこれらの新しいページに加えて、Railsはconfig/routes.rbにあるルートファイルを更新し、ホームページ用のgetルートを追加します。これはルートルートとして変更します。

A root route in Rails specifies what will show up when users visit the root URL of your application. In this case, you want your users to see your homepage. Open the routes file located at config/routes.rb in your favorite editor:

  1. nano config/routes.rb

このファイルでは、get 'homepage/index'root 'homepage#index'に置き換えて、ファイルが次のように一致するようにします:

~/rails_react_recipe/config/routes.rb
Rails.application.routes.draw do
  root 'homepage#index'
  # このファイル内で利用可能なDSLの詳細については、http://guides.rubyonrails.org/routing.html を参照してください。
end

この修正は、RailsにリクエストをアプリケーションのルートからindexアクションのHomepageコントローラーにマップするように指示します。そして、ブラウザでindex.html.erbファイルがある場所にある内容をレンダリングします。app/views/homepage/index.html.erb

ファイルを保存して閉じます。

これが機能していることを確認するには、アプリケーションを起動します:

  1. bin/dev

ブラウザでアプリケーションを開いたり更新したりすると、アプリケーションの新しいランディングページが読み込まれます:

アプリケーションが動作していることを確認したら、サーバーを停止するにはCTRL+Cを押します。

次に、~/rails_react_recipe/app/views/homepage/index.html.erbファイルを開きます:

  1. nano ~/rails_react_recipe/app/views/homepage/index.html.erb

ファイル内のコードを削除して、空のファイルとして保存します。これにより、index.html.erbの内容がフロントエンドのReactのレンダリングと干渉しないようにします。

これでアプリケーションのホームページの設定が完了しました。次のセクションに移動し、アプリケーションのフロントエンドをReactで使用するように構成します。

ステップ5 — RailsフロントエンドとしてReactを構成する

このステップでは、Railsを設定して、アプリケーションのフロントエンドにテンプレートエンジンの代わりにReactを使用します。この新しい設定により、Reactを使用してよりビジュアルに魅力的なホームページを作成することができます。

Railsアプリケーションの生成時に指定されたesbuildオプションの助けを借りて、JavaScriptがRailsとシームレスに連携するために必要なほとんどのセットアップは既に完了しています。残りの作業は、ReactアプリケーションのエントリーポイントをesbuildのJavaScriptファイルのエントリーポイントにロードすることです。これを行うには、まずapp/javascriptディレクトリにcomponentsディレクトリを作成します。

  1. mkdir ~/rails_react_recipe/app/javascript/components

componentsディレクトリには、ホームページのコンポーネントと、アプリケーション内の他のReactコンポーネント(Reactアプリケーションへのエントリーファイルを含む)が格納されます。

次に、app/javascript/application.jsにあるapplication.jsファイルを開きます。

  1. nano ~/rails_react_recipe/app/javascript/application.js

以下のコードをファイルに追加します。

~/rails_react_recipe/app/javascript/application.js
// パッケージのpackage.jsonのビルドスクリプトのエントリーポイント
import "@hotwired/turbo-rails"
import "./controllers"
import * as bootstrap from "bootstrap"
import "./components"

application.jsファイルに追加されたコードは、エントリーのindex.jsxファイルのコードをインポートし、esbuildでバンドルできるようにします。RailsアプリのJavaScriptエントリーポイントに/componentsディレクトリがインポートされたので、ホームページのためのReactコンポーネントを作成できます。ホームページにはテキストと、すべてのレシピを表示するためのアクションボタンが含まれます。

ファイルを保存して閉じます。

次に、componentsディレクトリにHome.jsxファイルを作成します。

  1. nano ~/rails_react_recipe/app/javascript/components/Home.jsx

以下のコードをファイルに追加します。

~/rails_react_recipe/app/javascript/components/Home.jsx
import React from "react";
import { Link } from "react-router-dom";

export default () => (
  <div className="vw-100 vh-100 primary-color d-flex align-items-center justify-content-center">
    <div className="jumbotron jumbotron-fluid bg-transparent">
      <div className="container secondary-color">
        <h1 className="display-4">Food Recipes</h1>
        <p className="lead">
          A curated list of recipes for the best homemade meal and delicacies.
        </p>
        <hr className="my-4" />
        <Link
          to="/recipes"
          className="btn btn-lg custom-button"
          role="button"
        >
          View Recipes
        </Link>
      </div>
    </div>
  </div>
);

このコードでは、ReactとReact RouterからLinkコンポーネントをインポートします。 Linkコンポーネントは、ページ間を移動するためのハイパーリンクを作成します。 その後、ホームページのためのいくつかのマークアップ言語を含む関数コンポーネントを作成し、Bootstrapクラスでスタイルを付けてエクスポートします。

ファイルを保存して閉じます。

Homeコンポーネントが設定されたら、React Routerを使用してルーティングを設定します。 app/javascriptディレクトリにroutesディレクトリを作成します:

  1. mkdir ~/rails_react_recipe/app/javascript/routes

routesディレクトリには、いくつかのルートとそれに対応するコンポーネントが含まれます。 指定されたルートがロードされるたびに、対応するコンポーネントがブラウザにレンダリングされます。

routesディレクトリに、index.jsxファイルを作成します:

  1. nano ~/rails_react_recipe/app/javascript/routes/index.jsx

次のコードを追加します:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";

export default (
  <Router>
    <Routes>
      <Route path="/" element={<Home />} />
    </Routes>
  </Router>
);

このindex.jsxルートファイルでは、次のモジュールをインポートします:Reactを使用するためのReactモジュール、およびReact RouterからのBrowserRouterRoutes、およびRouteモジュール。 これらは、異なるルート間を移動するのに役立ちます。 最後に、ルート(/)に一致するリクエストがあるたびにレンダリングされるHomeコンポーネントをインポートします。

ファイルを保存して終了します。

React Routerを使用してルーティングを設定しました。 Reactが利用可能なルートを認識して使用するためには、これらのルートをアプリケーションのエントリーポイントで利用できる必要があります。 これを実現するには、Reactがエントリーファイルでレンダリングするコンポーネント内でルートをレンダリングします。

app/javascript/componentsディレクトリにApp.jsxファイルを作成してください:

  1. nano ~/rails_react_recipe/app/javascript/components/App.jsx

App.jsxファイルに次のコードを追加します:

~/rails_react_recipe/app/javascript/components/App.jsx
import React from "react";
import Routes from "../routes";

export default props => <>{Routes}</>;

App.jsxファイルでは、Reactとさきほど作成したルートファイルをインポートします。 その後、ルートをレンダリングするコンポーネントをエクスポートします。フラグメント内で。 このコンポーネントは、アプリケーションが読み込まれるたびに、アプリケーションのエントリーポイントでレンダリングされます。

ファイルを保存して閉じます。

App.jsxを設定したので、エントリーファイルでレンダリングできます。 componentsディレクトリにindex.jsxファイルを作成してください:

  1. nano ~/rails_react_recipe/app/javascript/components/index.jsx

index.jsファイルに次のコードを追加します:

~/rails_react_recipe/app/javascript/components/index.jsx
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

document.addEventListener("turbo:load", () => {
  const root = createRoot(
    document.body.appendChild(document.createElement("div"))
  );
  root.render(<App />);
});

import行で、Reactライブラリ、ReactDOMからcreateRoot関数、およびAppコンポーネントをインポートします。 ReactDOMのcreateRoot関数を使用して、ページに追加されたdiv要素としてルート要素を作成し、その中にAppコンポーネントをレンダリングします。 アプリケーションが読み込まれると、ReactはAppコンポーネントのコンテンツをページ上のdiv要素内にレンダリングします。

ファイルを保存して閉じます。

最後に、ホームページにいくつかのCSSスタイルを追加します。

`application.bootstrap.scss`ファイルを~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scssディレクトリで開いてください:

  1. nano ~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scss

次に、application.bootstrap.scssファイルの内容を以下のコードで置き換えてください:

~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scss
@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons';

.bg_primary-color {
  background-color: #FFFFFF;
}
.primary-color {
  background-color: #FFFFFF;
}
.bg_secondary-color {
  background-color: #293241;
}
.secondary-color {
  color: #293241;
}
.custom-button.btn {
  background-color: #293241;
  color: #FFF;
  border: none;
}
.hero {
  width: 100vw;
  height: 50vh;
}
.hero img {
  object-fit: cover;
  object-position: top;
  height: 100%;
  width: 100%;
}
.overlay {
  height: 100%;
  width: 100%;
  opacity: 0.4;
}

ページのためにカスタムの色を設定します。.heroセクションは、ウェブサイトのトップページに大きなウェブバナーまたはヒーローイメージのフレームワークを作成します。また、custom-button.btnは、ユーザーがアプリケーションに入るために使用するボタンにスタイルを付けます。

CSSスタイルが設定されたら、ファイルを保存して終了します。

次に、アプリケーションのウェブサーバーを再起動してください:

  1. bin/dev

その後、ブラウザでアプリケーションをリロードしてください。新しいホームページがロードされます:

ウェブサーバーをCTRL+Cで停止してください。

このステップで、アプリケーションをフロントエンドとしてReactを使用するように構成しました。次のステップでは、レシピを作成、読み取り、更新、削除するためのモデルとコントローラーを作成します。

ステップ6 — レシピコントローラーとモデルの作成

あなたのアプリケーションにReactフロントエンドを設定したので、次にレシピモデルとコントローラーを作成します。

レシピモデルを作成するには、Railsが提供するgenerate modelサブコマンドを使用し、モデルの名前とそのカラム及びデータ型を指定して、以下のコマンドを実行します:

  1. rails generate model Recipe name:string ingredients:text instruction:text image:string

前述のコマンドはRailsにRecipeモデルをnameカラム(型はstring)、ingredientsinstructionカラム(型はtext)、そしてimageカラム(型はstring)と共に作成するよう指示します。このチュートリアルでは、モデルにRecipeという名前を付けています。なぜならRailsのモデルは単数形の名前を使用し、それに対応するデータベーステーブルは複数形の名前を使用するからです。

generate modelコマンドを実行すると、2つのファイルが作成され、以下の出力が表示されます:

Output
invoke active_record create db/migrate/20221017220817_create_recipes.rb create app/models/recipe.rb

作成された2つのファイルは:

  • A recipe.rb file that holds all the model-related logic.
  • A 20221017220817_create_recipes.rb file (the number at the beginning of the file may differ depending on the date when you run the command). This migration file contains the instruction for creating the database structure.

次に、データベースに保存されるデータが有効であることを確認するために、レシピモデルファイルを編集します。これは、モデルにいくつかのデータベースバリデーションを追加することで達成できます。

app/models/recipe.rbにあるレシピモデルを開きます:

  1. nano ~/rails_react_recipe/app/models/recipe.rb

次のコード行をファイルに追加してください:

~/rails_react_recipe/app/models/recipe.rb
class Recipe < ApplicationRecord
  validates :name, presence: true
  validates :ingredients, presence: true
  validates :instruction, presence: true
end

このコードでは、モデル検証を追加し、nameingredients、およびinstructionフィールドの存在をチェックします。これらの三つのフィールドがないと、レシピは無効になり、データベースに保存されません。

ファイルを保存して閉じてください。

データベース内にrecipesテーブルを作成するために、Railsでマイグレーションを実行する必要があります。これはデータベースにプログラムで変更を加える方法です。マイグレーションが設定したデータベースで動作するようにするために、20221017220817_create_recipes.rbファイルを変更する必要があります。

エディタでこのファイルを開いてください:

  1. nano ~/rails_react_recipe/db/migrate/20221017220817_create_recipes.rb

次のようにファイルを一致させるために、ハイライトされた素材を追加してください:

db/migrate/20221017220817_create_recipes.rb
class CreateRecipes < ActiveRecord::Migration[5.2]
  def change
    create_table :recipes do |t|
      t.string :name, null: false
      t.text :ingredients, null: false
      t.text :instruction, null: false
      t.string :image, default: 'https://raw.githubusercontent.com/do-community/react_rails_recipe/master/app/assets/images/Sammy_Meal.jpg'

      t.timestamps
    end
  end
end

このマイグレーションファイルには、changeメソッドを持つRubyクラスと、recipesというテーブルを作成するコマンドが含まれています。列とそのデータ型も含まれています。また、20221017220817_create_recipes.rbnull: falseを追加して、nameingredients、およびinstruction列にNOT NULL制約を追加し、これらの列がデータベースを変更する前に値を持っていることを確認します。最後に、イメージ列のデフォルトのイメージURLを追加します。別のイメージを使用したい場合は、別のURLを使用できます。

これらの変更で、ファイルを保存して終了します。これで、マイグレーションを実行してテーブルを作成する準備が整いました。ターミナルで次のコマンドを実行してください:

  1. rails db:migrate

データベースのマイグレーションコマンドを使用して、マイグレーションファイル内の指示を実行します。コマンドが成功裏に実行されると、次のような出力が表示されます:

Output
== 20190407161357 CreateRecipes: migrating ==================================== -- create_table(:recipes) -> 0.0140s == 20190407161357 CreateRecipes: migrated (0.0141s) ===========================

レシピモデルを設定したら、次にレシピコントローラーを作成して、レシピの作成、読み取り、削除のロジックを追加します。次のコマンドを実行します:

  1. rails generate controller api/v1/Recipes index create show destroy --skip-template-engine --no-helper

このコマンドでは、api/v1ディレクトリにRecipesコントローラーを作成し、indexcreateshowdestroyアクションを追加します。indexアクションは、すべてのレシピを取得する処理を担当し、createアクションは新しいレシピを作成する責任があり、showアクションは単一のレシピを取得し、destroyアクションはレシピを削除するロジックを保持します。

コントローラーをより軽量にするために、いくつかのフラグを渡します:

  • --skip-template-engineは、Reactがフロントエンドのニーズを処理するため、Railsビューファイルの生成をスキップするようにRailsに指示します。
  • --no-helperは、コントローラー用のヘルパーファイルの生成をスキップするようにRailsに指示します。

コマンドを実行すると、Recipesコントローラーの各アクションに対するルートがルートファイルに更新されます。

コマンドを実行すると、このような出力が表示されます:

Output
create app/controllers/api/v1/recipes_controller.rb route namespace :api do namespace :v1 do get 'recipes/index' get 'recipes/create' get 'recipes/show' get 'recipes/destroy' end end

これらのルートを使用するために、config/routes.rbファイルを変更します。routes.rbファイルをテキストエディターで開きます:

  1. nano ~/rails_react_recipe/config/routes.rb

次のコードのようにこのファイルを更新し、強調表示された行を変更または追加します:

~/rails_react_recipe/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      get 'recipes/index'
      post 'recipes/create'
      get '/show/:id', to: 'recipes#show'
      delete '/destroy/:id', to: 'recipes#destroy'
    end
  end
  root 'homepage#index'
  get '/*path' => 'homepage#index'
  # https://guides.rubyonrails.org/routing.html で定義されたDSLに従ってアプリケーションのルートを定義します。

  # ルートパス ("/") を定義します。
  # root "articles#index"
end

このルートファイルでは、createdestroyのルートのHTTP動詞を変更し、データをpostおよびdeleteするようにします。また、showdestroyアクションのルートに:idパラメータを追加して、読み取りまたは削除するレシピの識別番号を保持します。

次に、既存のルートと一致しない他のリクエストをhomepageコントローラーのindexアクションにリダイレクトするget '/*path'のキャッチオールルートを追加します。フロントエンドのルーティングは、レシピの作成、読み取り、または削除と関係のないリクエストを処理します。

ファイルを保存して終了します。

アプリケーションで利用可能なルートの一覧を評価するには、次のコマンドを実行します:

  1. rails routes

このコマンドを実行すると、プロジェクトのURIパターン、動詞、および一致するコントローラーまたはアクションの長い一覧が表示されます。

次に、一度にすべてのレシピを取得するためのロジックを追加します。Railsは、ActiveRecordライブラリを使用して、このようなデータベース関連のタスクを処理します。ActiveRecordはクラスを関係データベーステーブルに接続し、それらとの作業に対する豊富なAPIを提供します。

すべてのレシピを取得するために、ActiveRecordを使用してレシピテーブルをクエリし、データベース内のすべてのレシピを取得します。

次のコマンドで`recipes_controller.rb`ファイルを開きます:

  1. nano ~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb

レシピコントローラにハイライトされた行を追加します:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
  end

  def show
  end

  def destroy
  end
end

`index`アクションでは、ActiveRecordの`all`メソッドを使用してデータベース内のすべてのレシピを取得します。`order`メソッドを使用して、作成日に基づいて降順で並べ替え、最新のレシピを最初に配置します。最後に、`render`を使用してレシピのリストをJSONレスポンスとして送信します。

次に、新しいレシピを作成するロジックを追加します。すべてのレシピを取得するのと同様に、提供されたレシピの詳細を検証し保存するためにActiveRecordに依存します。次のハイライトされたコード行でレシピコントローラを更新します:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
    recipe = Recipe.create!(recipe_params)
    if recipe
      render json: recipe
    else
      render json: recipe.errors
    end
  end

  def show
  end

  def destroy
  end

  private

  def recipe_params
    params.permit(:name, :image, :ingredients, :instruction)
  end
end

createアクションでは、ActiveRecordのcreateメソッドを使用して新しいレシピを作成します。createメソッドは、コントローラーが提供されたすべてのパラメーターを一度にモデルに割り当てることができます。このメソッドを使用すると、レコードを簡単に作成できますが、悪意のある使用の可能性が開かれます。悪意のある使用は、Railsが提供するストロングパラメーター機能を使用して防ぐことができます。これにより、許可されていない限り、パラメーターを割り当てることができません。コードでcreateメソッドにrecipe_paramsパラメーターを渡します。recipe_paramsは、コントローラーのパラメーターを許可してデータベースに誤ったまたは悪意のある内容が入らないようにするprivateメソッドです。この場合、createメソッドの正しい使用のためにnameimageingredients、およびinstructionパラメーターを許可します。

レシピコントローラーは今、レシピの読み取りと作成ができます。残りの作業は、単一のレシピを読み取りおよび削除するためのロジックです。レシピコントローラーを以下のコードで更新してください:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  before_action :set_recipe, only: %i[show destroy]

  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
    recipe = Recipe.create!(recipe_params)
    if recipe
      render json: recipe
    else
      render json: recipe.errors
    end
  end

  def show
    render json: @recipe
  end

  def destroy
    @recipe&.destroy
    render json: { message: 'Recipe deleted!' }
  end

  private

  def recipe_params
    params.permit(:name, :image, :ingredients, :instruction)
  end

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end
end

新しいコード行では、showdeleteアクションがリクエストと一致する場合にのみ、before_actionによって呼び出されるset_recipeメソッドを作成します。set_recipeメソッドでは、ActiveRecordのfindメソッドを使用して、paramsで提供されたidと一致するレシピを検索し、それをインスタンス変数@recipeに割り当てます。showアクションでは、set_recipeメソッドによって設定された@recipeオブジェクトをJSONレスポンスとして返します。

destroyアクションでは、Rubyの安全ナビゲーション演算子&.を使用して、似たようなことを行いました。これにより、メソッドの呼び出し時にnilエラーが発生しないようになります。この追加により、レシピが存在する場合にのみレシピを削除し、それに応答としてメッセージを送信できます。

recipes_controller.rbへのこれらの変更を行った後、ファイルを保存して閉じます。

この手順では、レシピのためのモデルとコントローラを作成しました。バックエンドでレシピを操作するために必要なすべてのロジックを記述しました。次のセクションでは、レシピを表示するためのコンポーネントを作成します。

ステップ7 — レシピの表示

このセクションでは、レシピの表示用のコンポーネントを作成します。既存のすべてのレシピを表示するページと、個々のレシピを表示するページの2つを作成します。

最初に、すべてのレシピを表示するページを作成します。ページを作成する前に、データベースが現在空であるため、作業するためのレシピが必要です。Railsは、アプリケーションのためのシードデータを作成する方法を提供します。

編集するために、seeds.rbというシードファイルを開きます:

  1. nano ~/rails_react_recipe/db/seeds.rb

シードファイルの初期コンテンツを以下のコードに置き換えます:

~/rails_react_recipe/db/seeds.rb
9.times do |i|
  Recipe.create(
    name: "Recipe #{i + 1}",
    ingredients: '227g tub clotted cream, 25g butter, 1 tsp cornflour,100g parmesan, grated nutmeg, 250g fresh fettuccine or tagliatelle, snipped chives or chopped parsley to serve (optional)',
    instruction: 'In a medium saucepan, stir the clotted cream, butter, and cornflour over a low-ish heat and bring to a low simmer. Turn off the heat and keep warm.'
  )
end

このコードでは、Railsに9つのレシピを作成するように指示するループを使用しています。各レシピにはnameingredientsinstructionのセクションがあります。ファイルを保存して終了します。

このデータでデータベースをシードするには、ターミナルで以下のコマンドを実行します:

  1. rails db:seed

このコマンドを実行すると、9つのレシピがデータベースに追加されます。これで、フロントエンドでそれらを取得して表示することができます。

すべてのレシピを表示するためのコンポーネントは、RecipesControllerindexアクションにHTTPリクエストを行って、すべてのレシピのリストを取得します。これらのレシピは、その後、ページ上のカードに表示されます。

app/javascript/componentsディレクトリにRecipes.jsxファイルを作成します:

  1. nano ~/rails_react_recipe/app/javascript/components/Recipes.jsx

ファイルを開いたら、次の行を追加して、ReactuseStateuseEffectLinkuseNavigateモジュールをインポートします:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

次に、ハイライトされた行を追加して、Recipesという名前の機能的なReactコンポーネントを作成してエクスポートします:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);
};

export default Recipes;

Recipeコンポーネント内では、React RouterのナビゲーションAPIがuseNavigateフックを呼び出します。ReactのuseStateフックはrecipesステートを初期化し、これは空の配列([])であり、recipesステートを更新するsetRecipes関数も初期化します。

次に、useEffectフックで、すべてのレシピを取得するためにHTTPリクエストを行います。これを行うには、次の行を追加します:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);

  useEffect(() => {
    const url = "/api/v1/recipes/index";
    fetch(url)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((res) => setRecipes(res))
      .catch(() => navigate("/"));
  }, []);
};

export default Recipes;

useEffectフックでは、Fetch APIを使用してすべてのレシピを取得するHTTPコールを行います。レスポンスが成功した場合、アプリケーションはレシピの配列をrecipesステートに保存します。エラーが発生した場合、ユーザーをホームページにリダイレクトします。

最後に、コンポーネントがレンダリングされるときにブラウザーページに評価および表示される要素のマークアップを返します。この場合、コンポーネントはrecipesステートからレシピのカードをレンダリングします。次の行をRecipes.jsxに追加します:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);

  useEffect(() => {
    const url = "/api/v1/recipes/index";
    fetch(url)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((res) => setRecipes(res))
      .catch(() => navigate("/"));
  }, []);

  const allRecipes = recipes.map((recipe, index) => (
    <div key={index} className="col-md-6 col-lg-4">
      <div className="card mb-4">
        <img
          src={recipe.image}
          className="card-img-top"
          alt={`${recipe.name} image`}
        />
        <div className="card-body">
          <h5 className="card-title">{recipe.name}</h5>
          <Link to={`/recipe/${recipe.id}`} className="btn custom-button">
            View Recipe
          </Link>
        </div>
      </div>
    </div>
  ));
  const noRecipe = (
    <div className="vw-100 vh-50 d-flex align-items-center justify-content-center">
      <h4>
        No recipes yet. Why not <Link to="/new_recipe">create one</Link>
      </h4>
    </div>
  );

  return (
    <>
      <section className="jumbotron jumbotron-fluid text-center">
        <div className="container py-5">
          <h1 className="display-4">Recipes for every occasion</h1>
          <p className="lead text-muted">
            We’ve pulled together our most popular recipes, our latest
            additions, and our editor’s picks, so there’s sure to be something
            tempting for you to try.
          </p>
        </div>
      </section>
      <div className="py-5">
        <main className="container">
          <div className="text-end mb-3">
            <Link to="/recipe" className="btn custom-button">
              Create New Recipe
            </Link>
          </div>
          <div className="row">
            {recipes.length > 0 ? allRecipes : noRecipe}
          </div>
          <Link to="/" className="btn btn-link">
            Home
          </Link>
        </main>
      </div>
    </>
  );
};

export default Recipes;

Recipes.jsxを保存して終了します。

これで、すべてのレシピを表示するコンポーネントが作成されたので、それに対するルートを作成します。フロントエンドのルートファイルapp/javascript/routes/index.jsxを開きます:

  1. nano app/javascript/routes/index.jsx

ファイルに次の行を追加します:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" element={<Recipes />} />
    </Routes>
  </Router>
);

ファイルを保存して終了します。

この時点で、コードが期待通りに機能しているか確認するのは良いアイデアです。 以前と同様に、次のコマンドを使用してサーバーを起動します:

  1. bin/dev

次に、ブラウザーでアプリを開きます。 ホームページでView Recipeボタンを押して、シードレシピを表示するページにアクセスします:

ターミナルでCTRL+Cを使用してサーバーを停止し、プロンプトに戻ります。

今やアプリケーション内のすべてのレシピを表示できるようになったので、個々のレシピを表示するための2番目のコンポーネントを作成する時が来ました。 app/javascript/componentsディレクトリにRecipe.jsxファイルを作成します:

  1. nano app/javascript/components/Recipe.jsx

Recipesコンポーネントと同様に、次の行を追加してReactuseStateuseEffectLinkuseNavigate、およびuseParamモジュールをインポートします:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

次に、ハイライトされた行を追加して、Recipeという名前の関数型Reactコンポーネントを作成してエクスポートします:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });
};

export default Recipe;

Recipesコンポーネントと同様に、React RouterナビゲーションをuseNavigateフックで初期化します。 useStateフックでrecipeステートとsetRecipe関数を初期化します。 さらに、URLパラメータのキー/値ペアを持つオブジェクトを返すuseParamsフックを呼び出します。

特定のレシピを見つけるには、アプリケーションがレシピのidを知る必要があります。 これは、RecipeコンポーネントがURLでid paramを期待していることを意味します。 これには、useParamsフックの戻り値を保持するparamsオブジェクトを介してアクセスできます。

次に、useEffectフックを宣言し、paramsオブジェクトからid paramにアクセスします。レシピのidパラメーターを取得したら、レシピを取得するためにHTTPリクエストを行います。ファイルに以下の強調された行を追加してください:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);
};

export default Recipe;

useEffectフックでは、params.idの値を使用して、idを所有するレシピを取得するためにGET HTTPリクエストを行い、それをコンポーネントの状態にsetRecipe関数を使用して保存します。レシピが存在しない場合、アプリはユーザーをレシピページにリダイレクトします。

次に、コンポーネント内で文字エンティティをHTMLエンティティに置き換えるaddHtmlEntities関数を追加します。addHtmlEntities関数は、文字列を受け取り、すべてのエスケープされた開き括弧と閉じ括弧をそれらのHTMLエンティティに置き換えます。この関数は、レシピの指示に保存されていたエスケープされた文字を変換するのに役立ちます。以下の強調された行を追加してください:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };
};

export default Recipe;

最後に、強調された行を追加して、コンポーネントの状態にあるレシピをページ上にレンダリングするためのマークアップを返します。

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);
  
  return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
};

export default Recipe;

ingredientList 関数を使用すると、カンマで区切られたレシピの材料が配列に分割され、それをマップして材料のリストが作成されます。 材料がない場合、アプリは「利用可能な材料はありません」というメッセージを表示します。 レシピの指示に含まれるすべての開始および終了ブラケットも、addHtmlEntities 関数を通して置き換えます。 最後に、コードはレシピ画像をヒーロー画像として表示し、レシピ指示の隣にレシピの削除ボタンを追加し、レシピページへのリンクを追加します。

注意: ReactのdangerouslySetInnerHTML属性を使用することはリスクが伴います。これにより、アプリがクロスサイトスクリプティング攻撃にさらされる可能性があります。 このリスクは、レシピを作成する際に入力された特殊文字が、NewRecipeコンポーネントで宣言されたstripHtmlEntities関数を使用して置換されることによって軽減されます。

保存してファイルを閉じます。

Recipeコンポーネントをページで表示するには、それをルートファイルに追加します。 ルートファイルを編集するには、次の手順を実行します:

  1. nano app/javascript/routes/index.jsx

ファイルに以下のハイライトされた行を追加します:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";
import Recipe from "../components/Recipe";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" exact component={Recipes} />
      <Route path="/recipe/:id" element={<Recipe />} />
    </Routes>
  </Router>
);

このルートファイルでRecipeコンポーネントをインポートし、ルートを追加します。 ルートには:id paramがあり、表示したいレシピのidで置き換えられます。

ファイルを保存して閉じます。

bin/devスクリプトを使用してサーバーを再起動し、ブラウザでhttp://localhost:3000にアクセスします。 View Recipesボタンをクリックしてレシピページに移動します。 レシピページでは、任意のレシピをクリックしてそのView Recipeボタンにアクセスします。 データベースからのデータでポップアップしたページが表示されます:

サーバーをCTRL+Cで停止できます。

このステップでは、データベースに9つのレシピを追加し、これらのレシピを個別およびコレクションとして表示するためのコンポーネントを作成しました。 次のステップでは、レシピを作成するコンポーネントを追加します。

ステップ8 — レシピの作成

使いやすい料理レシピアプリを作るための次のステップは、新しいレシピを作成する機能です。 このステップでは、この機能用のコンポーネントを作成します。 コンポーネントには、ユーザーから必要なレシピの詳細を収集するフォームが含まれ、その後、レシピデータを保存するためにRecipeコントローラーのcreateアクションにリクエストが行われます。

app/javascript/componentsディレクトリにNewRecipe.jsxファイルを作成します:

  1. nano app/javascript/components/NewRecipe.jsx

新しいファイルで、他のコンポーネントで使用したReactuseStateLink、およびuseNavigateモジュールをインポートします。

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

次に、ハイライトされた行を追加して、NewRecipeコンポーネントを作成してエクスポートします。

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");
};

export default NewRecipe;

前のコンポーネントと同様に、ReactルーターナビゲーションをuseNavigateフックで初期化し、useStateフックを使用してnameingredients、およびinstructionの各状態を初期化します。それぞれに対応する更新関数があります。これらは有効なレシピを作成するために必要なフィールドです。

次に、stripHtmlEntities関数を作成します。この関数は特殊文字(<など)をそのエスケープ/エンコードされた値(&lt;など)に変換します。これには、NewRecipeコンポーネントに以下のハイライトされた行を追加します:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };
};

export default NewRecipe;

stripHtmlEntities関数では、<および>文字をそれぞれエスケープされた値で置き換えます。これにより、データベースに生のHTMLを保存しなくて済みます。

次に、NewRecipeコンポーネントにonChangeおよびonSubmit関数を追加して、フォームの編集と送信を処理します:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };

  const onChange = (event, setFunction) => {
    setFunction(event.target.value);
  };

  const onSubmit = (event) => {
    event.preventDefault();
    const url = "/api/v1/recipes/create";

    if (name.length == 0 || ingredients.length == 0 || instruction.length == 0)
      return;

    const body = {
      name,
      ingredients,
      instruction: stripHtmlEntities(instruction),
    };

    const token = document.querySelector('meta[name="csrf-token"]').content;
    fetch(url, {
      method: "POST",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => navigate(`/recipe/${response.id}`))
      .catch((error) => console.log(error.message));
  };
};

export default NewRecipe;

onChange関数は、ユーザーの入力eventと状態設定関数を受け取り、その後ユーザーの入力値で状態を更新します。 onSubmit関数では、必須の入力が空でないことを確認します。次に、新しいレシピを作成するために必要なパラメータを含むオブジェクトを構築します。 stripHtmlEntities関数を使用して、レシピの指示内の<および>文字をそのエスケープされた値で置き換え、改行文字をブレークタグで置き換えることにより、ユーザーが入力したテキスト形式を保持します。 最後に、新しいレシピを作成するためのPOST HTTPリクエストを行い、成功した応答でそのページにリダイレクトします。

Cross-Site Request Forgery (CSRF)攻撃から保護するために、RailsはHTMLドキュメントにCSRFセキュリティトークンを添付します。このトークンは、GET以外のリクエストが行われるたびに必要です。前のコードにあるtoken定数を使用して、アプリケーションはサーバーでトークンを検証し、セキュリティトークンが期待されるものと一致しない場合は例外をスローします。 onSubmit関数では、アプリケーションはRailsに埋め込まれたCSRFトークンを取得し、その後JSON文字列でHTTPリクエストを行います。レシピが正常に作成された場合、アプリケーションはユーザーをレシピページにリダイレクトし、新しく作成されたレシピを表示できます。

最後に、ユーザーが作成したいレシピの詳細を入力するためのフォームをレンダリングするマークアップを返します。ハイライトされた行を追加します:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };

  const onChange = (event, setFunction) => {
    setFunction(event.target.value);
  };

  const onSubmit = (event) => {
    event.preventDefault();
    const url = "/api/v1/recipes/create";

    if (name.length == 0 || ingredients.length == 0 || instruction.length == 0)
      return;

    const body = {
      name,
      ingredients,
      instruction: stripHtmlEntities(instruction),
    };

    const token = document.querySelector('meta[name="csrf-token"]').content;
    fetch(url, {
      method: "POST",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => navigate(`/recipe/${response.id}`))
      .catch((error) => console.log(error.message));
  };

  return (
    <div className="container mt-5">
      <div className="row">
        <div className="col-sm-12 col-lg-6 offset-lg-3">
          <h1 className="font-weight-normal mb-5">
            Add a new recipe to our awesome recipe collection.
          </h1>
          <form onSubmit={onSubmit}>
            <div className="form-group">
              <label htmlFor="recipeName">Recipe name</label>
              <input
                type="text"
                name="name"
                id="recipeName"
                className="form-control"
                required
                onChange={(event) => onChange(event, setName)}
              />
            </div>
            <div className="form-group">
              <label htmlFor="recipeIngredients">Ingredients</label>
              <input
                type="text"
                name="ingredients"
                id="recipeIngredients"
                className="form-control"
                required
                onChange={(event) => onChange(event, setIngredients)}
              />
              <small id="ingredientsHelp" className="form-text text-muted">
                Separate each ingredient with a comma.
              </small>
            </div>
            <label htmlFor="instruction">Preparation Instructions</label>
            <textarea
              className="form-control"
              id="instruction"
              name="instruction"
              rows="5"
              required
              onChange={(event) => onChange(event, setInstruction)}
            />
            <button type="submit" className="btn custom-button mt-3">
              Create Recipe
            </button>
            <Link to="/recipes" className="btn btn-link mt-3">
              Back to recipes
            </Link>
          </form>
        </div>
      </div>
    </div>
  );
};

export default NewRecipe;

返されたマークアップには、3つの入力フィールドが含まれるフォームが含まれています。それぞれが recipeNamerecipeIngredientsinstruction の入力フィールドです。各入力フィールドには onChange イベントハンドラがあり、onChange 関数を呼び出します。送信ボタンには onSubmit イベントハンドラも付いており、フォームデータを送信する onSubmit 関数を呼び出します。

ファイルを保存して終了します。

このコンポーネントにブラウザでアクセスするには、ルートファイルをそのルートで更新します:

  1. nano app/javascript/routes/index.jsx

これらのハイライトされた行を含むルートファイルを更新します:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";
import Recipe from "../components/Recipe";
import NewRecipe from "../components/NewRecipe";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" exact component={Recipes} />
      <Route path="/recipe/:id" exact component={Recipe} />
      <Route path="/recipe" element={<NewRecipe />} />
    </Routes>
  </Router>
);

ルートが設定されたら、ファイルを保存して閉じます。

開発サーバーを再起動し、ブラウザで http://localhost:3000 を開きます。レシピページに移動し、新しいレシピを作成 ボタンをクリックします。データベースにレシピを追加するフォームがあるページが表示されます:

必要なレシピの詳細を入力し、レシピを作成 ボタンをクリックします。新しく作成されたレシピがページに表示されます。準備ができたら、サーバーを閉じます。

このステップでは、料理レシピアプリケーションにレシピを作成する機能が追加されました。次のステップでは、レシピを削除する機能を追加します。

ステップ9 — レシピの削除

このセクションでは、レシピコンポーネントを変更して、レシピを削除するオプションを追加します。レシピページの削除ボタンをクリックすると、アプリケーションはデータベースからレシピを削除するリクエストを送信します。

まず、Recipe.jsxファイルを編集します:

  1. nano app/javascript/components/Recipe.jsx

Recipeコンポーネントで、ハイライトされた行にdeleteRecipe関数を追加します:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const deleteRecipe = () => {
    const url = `/api/v1/destroy/${params.id}`;
    const token = document.querySelector('meta[name="csrf-token"]').content;

    fetch(url, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then(() => navigate("/recipes"))
      .catch((error) => console.log(error.message));
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);

  return (
    <div className="">
...

deleteRecipe関数では、削除するレシピのidを取得し、その後URLを構築し、CSRFトークンを取得します。次に、Recipesコントローラに対してレシピを削除するDELETEリクエストを行います。レシピが正常に削除された場合、アプリケーションはユーザーをレシピページにリダイレクトします。

削除ボタンがクリックされるたびにdeleteRecipe関数内のコードを実行するには、ボタンにクリックイベントハンドラとしてそれを渡します。コンポーネント内の削除ボタン要素にonClickイベントを追加します:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
...
return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
              onClick={deleteRecipe}
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
...

このチュートリアルのこの時点で、完全なRecipe.jsxファイルはこのファイルと一致する必要があります:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const deleteRecipe = () => {
    const url = `/api/v1/destroy/${params.id}`;
    const token = document.querySelector('meta[name="csrf-token"]').content;

    fetch(url, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then(() => navigate("/recipes"))
      .catch((error) => console.log(error.message));
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);

  return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
              onClick={deleteRecipe}
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
};

export default Recipe;

ファイルを保存して終了します。

アプリケーションサーバーを再起動し、ホームページに移動します。すべての既存のレシピにアクセスするにはレシピを表示ボタンをクリックし、特定のレシピを開き、ページ上のレシピを削除ボタンをクリックして記事を削除します。レシピページにリダイレクトされ、削除されたレシピはもはや存在しません。

削除ボタンが機能するようになったので、完全に機能するレシピアプリケーションができました!

結論

このチュートリアルでは、Ruby on RailsとReactフロントエンドを使用して食事レシピアプリケーションを作成し、データベースとしてPostgreSQLを、スタイリングにはBootstrapを使用しました。Ruby on Railsでの開発を続けたい場合は、SSHトンネルを使用した3層Railsアプリケーションの通信のセキュリティ確保チュートリアルに従うか、Rubyのスキルをリフレッシュするために当社のRubyでコーディングする方法シリーズをご覧ください。Reactにさらに深く入りたい場合は、DigitalOcean APIからデータを表示する方法をお試しください。

Source:
https://www.digitalocean.com/community/tutorials/how-to-set-up-a-ruby-on-rails-v7-project-with-a-react-frontend-on-ubuntu-20-04