LoginSignup
5
6

More than 1 year has passed since last update.

~part2~【FC版】React + Rails API + axios + react-router-domでCRUDを実装する

Last updated at Posted at 2021-08-02

こんにちは!スージーです。
前回書いたこちらの続きです

やりたい事

  • モデルを1対1で関連付け
  • CRUDを実装

React × Rails APIで関連付けをしたモデルのCRUDを実装するにはこんな感じでやるんだなーって感じで見ていただけると幸いです

開発環境

Ruby 2.7.1
Rails 6.0.4
MySQL
node.js 14.8.0
React 17.0.2

参考

やらないこと

  • rails
    • トランザクションの説明
    • リレーションの説明
    • エラーハンドリング

まずgem foremanを入れる

毎回ローカルサーバを立ち上げる時に、rails syarn startをするのが面倒くさいので

backend $ gem install foreman
touch Procfile

backendディレクトリにProcfileを作成します

// Procfile
web: bundle exec rails s
npm: cd ../frontend && yarn start

gem foremanはデフォルトだとrails側が5000番ポート、client側が5100番ポートをlistenするのでcors.rbclient.jsを修正します

# cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:5100'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
// client.js
const client = applyCaseMiddleware(
  axios.create({
    baseURL: 'http://localhost:5000/api/v1',
  }),
  options
);

これで設定完了です

backend $ foreman start
07:06:36 web.1  | started with pid 6341
07:06:36 npm.1  | started with pid 6342
07:06:37 npm.1  | yarn run v1.22.10
07:06:37 npm.1  | $ react-scripts start
07:06:38 web.1  | => Booting Puma
07:06:38 web.1  | => Rails 6.0.4 application starting in development 
07:06:38 web.1  | => Run `rails server --help` for more startup options
07:06:39 web.1  | Puma starting in single mode...
07:06:39 web.1  | * Version 4.3.8 (ruby 2.7.1-p83), codename: Mysterious Traveller
07:06:39 web.1  | * Min threads: 5, max threads: 5
07:06:39 web.1  | * Environment: development
07:06:39 web.1  | * Listening on tcp://127.0.0.1:5000
07:06:39 web.1  | * Listening on tcp://[::1]:5000
07:06:39 web.1  | Use Ctrl-C to stop
07:06:45 npm.1  | ℹ 「wds」: Project is running at http://172.20.10.5/
07:06:45 npm.1  | ℹ 「wds」: webpack output is served from 
07:06:45 npm.1  | ℹ 「wds」: Content not from webpack is served from /Users/sugawarakouhei/project/local-project/react-form-sample/frontend/public
07:06:45 npm.1  | ℹ 「wds」: 404s will fallback to /
07:06:45 npm.1  | Starting the development server...

web => rails
npm => react
foreman startコマンド1つで両方のプロセスが実行される事が確認できればOK

modelを作成

postモデルと1対1(has_one)で関連付けるdetailInfoモデルを作成します

backend $ rails g model detailInfo
# 日付_create_detail_infos.rb
class CreateDetailInfos < ActiveRecord::Migration[6.0]
  def change
    create_table :detail_infos do |t|
      t.references :post, null: false
      t.string :favorite_food, limit: 100
      t.string :favorite_toy, limit: 100

      t.timestamps
    end
  end
end

外部キーと他に2つのカラムを持たせます。好きな食べ物好きなおもちゃです。全くテーブルを分ける必要がないデータ構造ですが、練習なので悪しからず...

# post.rb
class Post < ApplicationRecord
    has_one :detail_info, dependent: :destroy
end

dependent: :destroyは親モデルが物理削除された時に一緒に子モデルも物理削除します。

# detail_iinfo.rb
class DetailInfo < ApplicationRecord
    belongs_to :post
end

これで1対1の関係になりました

コントローラーの修正

class Api::V1::PostsController < ApplicationController

  def index
    render json: Post.all.to_json(include: :detail_info)
  end

  def show
    render json: Post.find(params[:id]).to_json(include: :detail_info)
  end

  def create
    ActiveRecord::Base.transaction do
      post = Post.new(post_params)
      detail_info = post.build_detail_info(detail_info_params)
      if post.save && detail_info.save
        render json: post.to_json(include: :detail_info)
      else
        ender json: { post: post.errors, detail_info: detail_info.errors }, status: 422
      end
    end
  end

  def update
    ActiveRecord::Base.transaction do
      post = Post.find(params[:id])
      detail_info = DetailInfo.find_by(post_id: params[:id])
        if post.update(post_params) && detail_info.update(detail_info_params)
          render json: post.to_json(include: :detail_info)
        else
          render json: { post: post.errors, detail_info: detail_info.errors }, status: 422
        end
    end
  end

  def destroy
    post = Post.find(params[:id])
    post.destroy
    render json: post.to_json(include: :detail_info)
  end

  private
    def post_params
      params.require(:post).permit(:name, :neko_type)
    end

    def detail_info_params
      params.require(:detail_info).permit(:favorite_food, :favorite_toy)
    end
end

render末尾に.to_json(include: :detail_info)を追加しました。リレーションを設定したデータを紐付けてjsonを返してくれるようになります。後ほどcurlコマンドを叩いて確認します

createアクションbuildメソッドを使って保存します。1対1のときはbuild_モデル名で保存します。

あとトランザクションを使い、失敗した時にはrollbackするように処理を囲みます。

複数テーブルへの保存処理にはaccepts_nested_attributes_for(非推奨)やFormオブジェクトを使う方法など存在します。ただこれらをうまく動作させる事ができなかった(筆者の知識が乏しく)のでtransactionbuildメソッドを使い、2つのモデルに登録する処理をcontrollerに書きました(fatコントローラーになってしまいました)

updateアクションcreateアクション同様にtrunsactionを使い処理を囲みます。findメソッドpostモデルから該当レコードを1件取得します。find_byメソッドを使い、親モデルと同じid(post_id)を持つ該当レコードをdetail_infoモデルから1件取得します。

seeds.rbを修正

post1 = Post.create!(name: 'ニャア', neko_type: 'アメリカンショートヘア')
DetailInfo.create!(post: post1, favorite_food: '魚', favorite_toy: '猫じゃらし')

post2 = Post.create!(name: 'まる', neko_type: 'スコッティシュフォールド')
DetailInfo.create!(post: post2, favorite_food: '野菜', favorite_toy: 'まりたん')

post3 = Post.create!(name: 'むぎ', neko_type: 'スコッティシュフォールド')
DetailInfo.create!(post: post3, favorite_food: '肉', favorite_toy: 'ダンボール')

以前の記事で作成したseeds.rbを修正しました。post: post1のように記載して、外部キーを持っている子モデルに対してcreateしています

curlコマンドを叩いて確認してみます

backend $  rails db:migrate:reset
rails db:seed

// 一覧
curl http://localhost:5000/api/v1/posts
[{"id":1,"name":"ニャア","neko_type":"アメリカンショートヘア","created_at":"2021-07-30T23:22:17.084Z","updated_at":"2021-07-30T23:22:17.084Z","detail_info":{"id":1,"post_id":1,"favorite_food":"魚","favorite_toy":"猫じゃらし","created_at":"2021-07-30T23:22:17.106Z","updated_at":"2021-07-30T23:22:17.106Z"}},{"id":2,"name":"まる","neko_type":"スコッティシュフォールド","created_at":"2021-07-30T23:22:17.111Z","updated_at":"2021-07-30T23:22:17.111Z","detail_info":{"id":2,"post_id":2,"favorite_food":"野菜","favorite_toy":"まりたん","created_at":"2021-07-30T23:22:17.116Z","updated_at":"2021-07-30T23:22:17.116Z"}},{"id":3,"name":"むぎ","neko_type":"スコッティシュフォールド","created_at":"2021-07-30T23:22:17.121Z","updated_at":"2021-07-30T23:22:17.121Z","detail_info":{"id":3,"post_id":3,"favorite_food":"肉","favorite_toy":"ダンボール","created_at":"2021-07-30T23:22:17.126Z","updated_at":"2021-07-30T23:22:17.126Z"}}]

// 詳細
curl http://localhost:5000/api/v1/posts/1
{"id":1,"name":"ニャア","neko_type":"アメリカンショートヘア","created_at":"2021-07-30T23:22:17.084Z","updated_at":"2021-07-30T23:22:17.084Z","detail_info":{"id":1,"post_id":1,"favorite_food":"魚","favorite_toy":"猫じゃらし","created_at":"2021-07-30T23:22:17.106Z","updated_at":"2021-07-30T23:22:17.106Z"}}

// 新規作成
curl -X POST "http://localhost:5000/api/v1/posts" -H "Accept: application/json" -H "Content-type: application/json" -d '{"post": {"name": "test","neko_type":"test"},"detail_info":{"favorite_food": "test","favorite_toy":"test"}}'
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"test","favorite_toy":"test","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:33:18.496Z"}}%

// 更新
curl -X PATCH "http://localhost:5000/api/v1/posts/4" -H "Accept: application/json" -H "Content-type: application/json" -d '{"post": {"name": "test","neko_type":"test"},"detail_info":{"favorite_food": "update3","favorite_toy":"update3"}}'
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"update3","favorite_toy":"update3","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:34:19.279Z"}}%

// 削除
curl -X DELETE http://localhost:5000/api/v1/posts/4
{"id":4,"name":"test","neko_type":"test","created_at":"2021-08-01T01:33:18.492Z","updated_at":"2021-08-01T01:33:18.492Z","detail_info":{"id":4,"post_id":4,"favorite_food":"update3","favorite_toy":"update3","created_at":"2021-08-01T01:33:18.496Z","updated_at":"2021-08-01T01:34:19.279Z"}}

[{"id": 1 ~~ detailInfo: {"id": 1, "post_id": 1 ~~} }]
オブジェクトの中にdetailInfoというキーでオブジェクトがネストされたデータ構造になっています。想定通りにデータが入っています

またpost / patchのリクエストを受けてcreateアクション / updateアクションが正常に動いている事が確認できました

deleteのリクエストを受けて親モデルのレコードが削除された時にはモデルに追記したdependent: :destroyによって子モデルのレコードも削除されている事が確認できました

一覧画面の修正

以前の記事でapiコールの実装は完了しているので、取得したデータの中にあるdetailInfo: {"id": 1, "post_id": 1 ~~}のオブジェクト内から必要なプロパティを展開し表示します

// List.jsx
import React, { useEffect, useState } from 'react';
// deletePostを追加
import { getList, deletePost } from '../lib/api/post';
import { useHistory, Link } from 'react-router-dom';

const List = () => {
  const [dataList, setDataList] = useState([]);

  useEffect(() => {
    handleGetList();
  }, []);

  const handleGetList = async () => {
    try {
      const res = await getList();
      console.log(res.data);
      setDataList(res.data);
    } catch (e) {
      console.log(e);
    }
  };

  const history = useHistory();

  const handleDelete = async (item) => {
    console.log('click', item.id);
    try {
      const res = await deletePost(item.id);
      console.log(res.data);
      handleGetList();
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <>
      <h1>HOME</h1>
      <button onClick={() => history.push('/new')}>新規作成</button>
      <table>
        <thead>
          <tr>
            <th>名前</th>
            <th>猫種</th>
            {/* 追加 */}
            <th>好きな食べ物</th>
            {/* 追加 */}
            <th>好きなおもちゃ</th>
            <th colSpan='1'></th>
            <th colSpan='1'></th>
            <th colSpan='1'></th>
          </tr>
        </thead>
        {dataList.map((item, index) => (
          <tbody key={index}>
            <tr>
              <td>{item.name}</td>
              <td>{item.nekoType}</td>
              {/* 追加 */}
              <td>{item.detailInfo.favoriteFood}</td>
              {/* 追加 */}
              <td>{item.detailInfo.favoriteToy}</td>
              <td>
                <Link to={`/edit/${item.id}`}>更新</Link>
              </td>
              <td>
                <Link to={`/post/${item.id}`}>詳細へ</Link>
              </td>
              <td>
                <button onClick={() => handleDelete(item)}>削除</button>
              </td>
            </tr>
          </tbody>
        ))}
      </table>
    </>
  );
};
export default List;

一覧に好きな食べ物好きなおもちゃが表示されていればOK
screencapture-localhost-3001-2021-08-01-08_51_51.png

詳細画面の修正

同じ要領で詳細画面の修正をしていきます。詳細画面ではquery.idの取得タイミングがrenderingより遅くなってしまうので{data.detailInfo.favoriteFood}TypeError: Cannot read property 'favoriteFood' of undefinedと怒られてしまいます。なのでstateに初期値を設定します

// Detail.jsx
import React, { useEffect, useState } from 'react';
import { getDetail } from '../lib/api/post';
import { useHistory, useParams } from 'react-router-dom';

const Detail = () => {
  // 修正
  const [data, setData] = useState({
    name: '',
    neko_type: '',
    detailInfo: {
      favorite_food: '',
      favorite_toy: '',
    },
  });

  const query = useParams();
  console.log(query.id);

  const history = useHistory();

  useEffect(() => {
    handleGetDetail(query);
  }, [query]);

  const handleGetDetail = async (query) => {
    try {
      const res = await getDetail(query.id);
      console.log(res.data);
      setData(res.data);
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <>
      <h1>DETAIL</h1>
      <div>ID:{data.id}</div>
      <div>名前:{data.name}</div>
      <div>猫種:{data.nekoType}</div>
      {/* 追加 */}
      <div>好きな食べ物:{data.detailInfo.favoriteFood}</div>
      {/* 追加 */}
      <div>好きなおもちゃ:{data.detailInfo.favoriteToy}</div>
      <button onClick={() => history.push('/')}>戻る</button>
    </>
  );
};
export default Detail;

好きな食べ物好きなおもちゃが表示されていればOK
screencapture-localhost-3001-post-1-2021-08-01-09_01_07.png

削除機能(追加無し)

削除機能は変更なしで削除できることが確認できればOK

新規作成の修正

新規作成を修正します

  • テキストフィールをForm.jsxに追加
  • Submitイベントでapiに送るパラメータのオブジェクト構造を加工する
// Form.jsx
import React from 'react';

const Form = (props) => {
  const { handleChange, handleSubmit, value, buttonType } = props;
  return (
    <>
      <form>
        <div>
          <label htmlFor='name'>猫の名前:</label>
          <input
            type='text'
            name='name'
            id='name'
            onChange={(e) => handleChange(e)}
            value={value.name}
          />
        </div>
        <div>
          <label htmlFor='nekoType'>猫種</label>
          <input
            type='text'
            name='nekoType'
            id='nekoType'
            onChange={(e) => handleChange(e)}
            value={value.nekoType}
          />
        </div>
        {/* 追加 */}
        <div>
          <label htmlFor='nekoType'>好きな食べ物</label>
          <input
            type='text'
            name='favoriteFood'
            id='favoriteFood'
            onChange={(e) => handleChange(e)}
            value={value.favoriteFood}
          />
        </div>
        <div>
          <label htmlFor='nekoType'>好きなおもちゃ</label>
          <input
            type='text'
            name='favoriteToy'
            id='favoriteToy'
            onChange={(e) => handleChange(e)}
            value={value.favoriteToy}
          />
        </div>
        {/* ここまで */}
        <input
          type='submit'
          value={buttonType}
          onClick={(e) => handleSubmit(e)}
        />
      </form>
    </>
  );
};
export default Form;
// New.jsx
import React, { useState } from 'react';
import FormBody from './Form';
import { createPost } from '../lib/api/post';
import { useHistory } from 'react-router-dom';

const New = () => {
  const [value, setValue] = useState({});
  const history = useHistory();

  const handleChange = (e) => {
    setValue({
      ...value,
      [e.target.name]: e.target.value,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    console.log(value);
    // 追加
    const params = generateParams();
    try {
      const res = await createPost(params);
      console.log(res);
      history.push('/');
    } catch (e) {
      console.log(e);
    }
  };

  // パラメータのオブジェクト構造を加工
  const generateParams = () => {
    const params = {
      name: value.name,
      nekoType: value.nekoType,
      // detailInfoというキーでオブジェクトをネストする
      detailInfo: {
        favoriteFood: value.favoriteFood,
        favoriteToy: value.favoriteToy,
      },
    };
    return params;
  };

  return (
    <>
      <h1>NEW</h1>
      <FormBody
        handleChange={handleChange}
        handleSubmit={handleSubmit}
        value={value}
        buttonType='登録'
      />
    </>
  );
};
export default New;

paramsの中身を見てみるとこのようなオブジェクトになっています
スクリーンショット 2021-08-02 10.10.13.png

登録ボタンを押下してデータが登録できればOK
railsのログを見てみると、パラメータを受け取って2つのinsert処理が走っている事がわかります。BEGINCOMMITに挟まれ処理が実行されているのでtrunsactionが効いている事も確認できます

10:11:26 web.1  | Started POST "/api/v1/posts" for ::1 at 2021-08-02 10:11:26 +0900
10:11:26 web.1  | Processing by Api::V1::PostsController#create as HTML
10:11:26 web.1  |   Parameters: {"name"=>"test", "neko_type"=>"test", "detail_info"=>{"favorite_food"=>"test", "favorite_toy"=>"test"}, "post"=>{"name"=>"test", "neko_type"=>"test"}}
10:11:26 web.1  |    (54.6ms)  BEGIN
10:11:26 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:26 web.1  |   Post Create (14.8ms)  INSERT INTO `posts` (`name`, `neko_type`, `created_at`, `updated_at`) VALUES ('test', 'test', '2021-08-02 01:11:26.894098', '2021-08-02 01:11:26.894098')
10:11:26 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:27 web.1  |   DetailInfo Create (26.6ms)  INSERT INTO `detail_infos` (`post_id`, `favorite_food`, `favorite_toy`, `created_at`, `updated_at`) VALUES (4, 'test', 'test', '2021-08-02 01:11:26.977189', '2021-08-02 01:11:26.977189')
10:11:27 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:15:in `block in create'
10:11:27 web.1  |    (6.9ms)  COMMIT
10:11:27 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:12:in `create'
10:11:27 web.1  | Completed 200 OK in 142ms (Views: 0.4ms | ActiveRecord: 102.9ms | Allocations: 4071)

更新画面を修正

最後に更新処理を修正します

  • stateの初期値を修正する
  • Submitイベントでapiに送るパラメータのオブジェクト構造を加工する
// Edit.jsx
import React, { useEffect, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { updatePost, getDetail } from '../lib/api/post';
import FormBody from './Form';

const Edit = () => {
  const [value, setValue] = useState({
    name: '',
    nekoType: '',
    // 追加
    favoriteFood: '',
    // 追加
    favoriteToy: '',
  });

  const query = useParams();

  const history = useHistory();

  useEffect(() => {
    handleGetData(query);
  }, [query]);

  const handleGetData = async (query) => {
    try {
      const res = await getDetail(query.id);
      console.log(res.data);
      setValue({
        name: res.data.name,
        nekoType: res.data.nekoType,
        // 追加
        favoriteFood: res.data.detailInfo.favoriteFood,
        // 追加
        favoriteToy: res.data.detailInfo.favoriteToy,
      });
    } catch (e) {
      console.log(e);
    }
  };

  const handleChange = (e) => {
    setValue({
      ...value,
      [e.target.name]: e.target.value,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 追加
    const params = generateParams();
    try {
      const res = await updatePost(query.id, params);
      console.log(res);
      history.push('/');
    } catch (e) {
      console.log(e);
    }
  };

  // パラメータのオブジェクト構造を加工
  const generateParams = () => {
    const params = {
      name: value.name,
      nekoType: value.nekoType,
      detailInfo: {
        favoriteFood: value.favoriteFood,
        favoriteToy: value.favoriteToy,
      },
    };
    return params;
  };

  return (
    <>
      <h1>Edit</h1>
      <FormBody
        handleChange={handleChange}
        handleSubmit={handleSubmit}
        value={value}
        buttonType='更新'
      />
    </>
  );
};
export default Edit;

修正箇所はNew.jsxとほとんど同じです

更新ボタンを押下してデータが更新できればOK
railsのログを見てみると、パラメータを受け取って2つのselectと2つのupdate処理が走っている事がわかります。id=4のレコードをpostテーブルから取得して更新処理をしています。post_id=4のレコードをdetailInfoテーブルから取得して更新処理をしています。BEGINCOMMITに挟まれ処理が実行されているのでtrunsactionが効いている事も確認できます

10:16:04 web.1  | Started PATCH "/api/v1/posts/4" for ::1 at 2021-08-02 10:16:04 +0900
10:16:04 web.1  | Processing by Api::V1::PostsController#update as HTML
10:16:04 web.1  |   Parameters: {"name"=>"update", "neko_type"=>"update", "detail_info"=>{"favorite_food"=>"udpate", "favorite_toy"=>"update"}, "id"=>"4", "post"=>{"name"=>"update", "neko_type"=>"update"}}
10:16:04 web.1  |    (0.2ms)  BEGIN
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:25:in `block in update'
10:16:04 web.1  |   Post Load (0.6ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`id` = 4 LIMIT 1
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:25:in `block in update'
10:16:04 web.1  |   DetailInfo Load (21.6ms)  SELECT `detail_infos`.* FROM `detail_infos` WHERE `detail_infos`.`post_id` = 4 LIMIT 1
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:26:in `block in update'
10:16:04 web.1  |   Post Update (12.4ms)  UPDATE `posts` SET `posts`.`name` = 'update', `posts`.`neko_type` = 'update', `posts`.`updated_at` = '2021-08-02 01:16:04.763790' WHERE `posts`.`id` = 4
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1  |   Post Load (14.5ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`id` = 4 LIMIT 1
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1  |   DetailInfo Update (1.0ms)  UPDATE `detail_infos` SET `detail_infos`.`favorite_food` = 'udpate', `detail_infos`.`favorite_toy` = 'update', `detail_infos`.`updated_at` = '2021-08-02 01:16:04.821784' WHERE `detail_infos`.`id` = 4
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:27:in `block in update'
10:16:04 web.1  |   DetailInfo Load (21.8ms)  SELECT `detail_infos`.* FROM `detail_infos` WHERE `detail_infos`.`post_id` = 4 LIMIT 1
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:28:in `block in update'
10:16:04 web.1  |    (7.4ms)  COMMIT
10:16:04 web.1  |   ↳ app/controllers/api/v1/posts_controller.rb:24:in `update'
10:16:04 web.1  | Completed 200 OK in 184ms (Views: 0.2ms | ActiveRecord: 79.4ms | Allocations: 6798)

最後に

コントローラがfatになっているので、もっと良い書き方があればご教示ください!

おわり
5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6