LoginSignup
19
18

Form Objectでデータを一括で保存する方法

Last updated at Posted at 2023-11-08

はじめに

はじめまして、yoshiと申します:woman:
現在未経験からエンジニアになることを目指して勉強中です。
今開発中のWebアプリ(JustBe U)で、RailsのFormオブジェクト使用する機会があったので備忘録として残します。
プログラミング初心者のため、技術的な部分で誤りを含む可能性があります。間違っている箇所を見つけた場合はお手数ですがご指摘いただけると幸いです。

やりたいこと

1日のよかったことを3つ記入して、form objectを使用して記入された3つのコンテンツを一括で保存できるようにする。
日記の新規作成と日記の編集ができるようにします。

Image from Gyazo

Image from Gyazo

参考記事

前提

Ruby: 3.2.2
Rails: 7.0.8
Devise *deviseについての説明は今回は省略させていただきます。

form object とは

RailsのFormオブジェクトは、フォームからデータを受け取るための独立したクラスです。これにより、単一のフォームで複数のモデルを扱うような複雑なデータの操作を簡単にすることができます。

form_withとは

Railsでフォームを作成するためのヘルパーメソッドです。このメソッドは、モデルを直接受け取るだけでなく、Formオブジェクトを引数として受け取ることもできます。Formオブジェクトをform_withに渡すと、そのオブジェクト内で定義された属性に基づいてフォームフィールドを作成できます。

テーブル

テーブルは以下のような感じです。
usersテーブルがあり、usersテーブルと1対多の関係にあるdiariesテーブルには日記の作成日をdateカラムに保存しています。そして、diariesテーブルと1対多の関係にあるdiary_entriesテーブルには、日記の内容を保存するcontentカラムがあります。
今回は、一つの日記に対して3つのコンテンツを保存するので日付を保存するdiariesテーブルとコンテンツを保存するdiary_entriesテーブルの2つに分けています。

Image from Gyazo

実装

日記機能の実装をしていきます。
基本的には、まず全体的なコードを提示してから細かな流れや説明を外部の方で行います。

1.モデル

has_manybelongs_toを使ってモデルの関連付けをしていきます。さらにバリデーションも書いていきます。

user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
  enum role: { general: 0, guest: 1, admin: 2 }
  has_many :diaries, dependent: :destroy

  validates :name, presence: true, length: { maximum: 255 }
end
diary_entry.rb
class DiaryEntry < ApplicationRecord
  belongs_to :diary
  validates :content, presence: true
end
diary.rb
class Diary < ApplicationRecord
  belongs_to :user
  has_many :diary_entries, dependent: :destroy
  validates :date,
            uniqueness: {
              scope: :user_id,
              message: lambda { |_object, data|
                " #{data[:value].to_date}の日記はすでに作成済みです"
              }
            } #date 属性が user_id スコープ内で一意であることを確認する
end

2. Formオブジェクト

まず初めに、appディレクトリーの配下にformsディレクトリーを作成する。
formsディレクトリー内に、今回はdiary_form.rbという名前のファイルを作成します。ファイル名は、<任意の名前>_form.rbとする必要があります。

diary_form.rb
class DiaryForm
  include ActiveModel::Model #バリデーションが使えるようになる

  attr_accessor :user_id, :entries_contents, :entries_ids #定義した属性がform_withで引数として使用できるなる

  def initialize(attributes = nil, diary: Diary.new)
    @diary = diary
    # attributesがnilまたは、falseであれば、||=の右の値が変数に代入されます。
    attributes ||= default_attributes
    super(attributes)
  end

  validate :all_entries_must_be_present

  def save
    return false unless valid?
    ActiveRecord::Base.transaction do # トランザクション内の処理が一つでも失敗すれば、ロールバックされます。
      diary = Diary.create!(user_id:, date: Time.current.to_date)
      entries_contents.each do |content|
        diary.diary_entries.create!(content:)
      end
    end
  rescue ActiveRecord::RecordInvalid => e #処理が失敗した時の処理を書く
    e.record.errors.full_messages.each do |message|
      errors.add(:base, message)
    end
    false
  end

  def update_diary
    return false unless valid?
    ActiveRecord::Base.transaction do #トランザクション内の処理が一つでも失敗すれば、ロールバックされます。
     entries_ids.each_with_index do |id, index| #要素をループしながら、それぞれの要素が元の配列の何番目にあるかを簡単に知ることができます。
        entry = DiaryEntry.find(id)
        entry.update!(content: entries_contents[index])
      end
    end

    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  private

  attr_reader :diary #上記の@diaryインスタンス変数に読み取り専用でアクセスするために使うことができる。
  private

  attr_reader :diary

  def default_attributes
    # @diaryインスタンス変数を直接参照するのではなく、
    # attr_readerで定義したdiaryメソッドを通して参照している
    {
      user_id: diary.user_id,  
      entries_contents: diary.diary_entries.map(&:content),
      entries_ids: diary.diary_entries.map(&:id)   
    }
  end

  def all_entries_must_be_present
    errors.add(:base, 'すべてのフォームに入力してください') if entries_contents.any?(&:blank?)
  end
end

2.1 include ActiveModel::Modelについて

RailsのFormオブジェクトにinclude ActiveModel::Modelを書くと、そのオブジェクトはRailsのモデルのように振る舞えるようになります。
具体的には、以下の機能が使えるようになります:

  1. バリデーション: データが正しいかどうかをチェックする機能です。例えば、メールアドレスが本当にメールアドレスが一意であるか、またはユーザー名が必ず入力されているかなどをチェックできます。

  2. フォームヘルパー: フォームを簡単に作成するためのヘルパーメソッドが使えます。これにより、form_withのようなメソッドを使って、フォームの入力欄を作ることができます。

  3. トランザクション: 複数の変更を一つの作業としてまとめて、もし何か一部がうまくいかなかった場合には、その変更をすべて元に戻すことができます。

などなど

ActiveModel::Modelをincludeすると、ActiveModel::APIのすべての機能を利用できるようになります。
モデル名のイントロスペクション
変換
翻訳(i18n)
バリデーション
また、Active Recordオブジェクトと同様に、属性のハッシュを持つオブジェクトを初期化する機能も使えます。
ActiveModel::APIをincludeしたクラスは、Active Recordオブジェクトと同様に、form_withやrenderなどのAction Viewヘルパーでも利用できます。
Railsガイド

2.2 attr_accessor について

attr_accessor :user_id, :entries_contents, :entries_ids

attr_accessorはatter_readerとattr_writerを合体させたメソッドです。
attr_accessorを使用することで、オブジェクトの特定の属性に簡単にアクセスしたり、その属性の値を設定したりすることができるようになります。

2.2.1 atter_readerとatter_writerについて

例えばDiaryというクラスがあったとします。

class Diary
  def text
    @text
  end
    
  def text=(value)
    @text = value
  end
end

diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"

atter_readerを使用すると上記のコードを以下のように省略するすることができます。

class Diary
  atter_reader :text

  def text=(value)
    @text = value
  end
end

diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"

そして、atter_writerを使うとさらに以下のように省略できるようになります。

class Diary
  atter_reader :text
  atter_writer :text
end

diary = Diary.new
diary.text = "AAA"
p diary.text #=> "AAA"

上記のatter_readerとatter_writerを一つにまとめると、attr_accessor :textになります。
これでdiaryオブジェクトが持つ@textをオブジェクトの外からも取得できるようになります。

2.3 initializeメソッド

initializeメソッドは、DiaryFormオブジェクトの新しいインスタンスが作成されるときに呼び出されます。
メソッドはデフォルトでattributes = nil と diary: Diary.newを引数に取っています。
attributesがnilの場合はdefault_attributesメソッドが呼び出され、diaryオブジェクトにデフォルト値がセットされます。

2.3.1 super(attributes)について

super(attributes)というコード行は、DiaryFormクラスが初期化される際に、ActiveModel::Modelから継承したinitializeメソッドを呼び出しています。ここでattributesという引数を親クラスのメソッドに渡すことにより、DiaryFormの属性が設定されます。

[superについての説明がわかりやすく書かれています]
https://tomo-bb-aki0117115.hatenablog.com/entry/2020/11/01/010733

2.4 トランザクション

saveメソッド内で、ActiveRecord::Base.transactionブロックを使ってデータベースのトランザクションを開始します。トランザクションを使用する理由は、複数の更新操作を一つの作業単位として扱い、何か問題が発生した場合には全ての変更を元に戻すためです。

3 コントローラとビュー

日記の新規作成と編集の処理はDiariesControllerの方に書いていきます。

diaries_controller.rb
class DiariesController < ApplicationController
  before_action :set_diary, only: [:show, :edit, :update, :destroy]

  def new
    @diary_form = DiaryForm.new
  end

  def create
    @diary_form = DiaryForm.new(diary_form_params.merge(user_id: current_user.id))

    if @diary_form.save
      redirect_to diaries_path, success: t('.success')
    else
      flash.now[:danger] = t('.fail')
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    @diary_form = DiaryForm.new(diary: @diary)
  end

  def update
    @diary_form = DiaryForm.new(diary_form_params, diary: @diary)
    if @diary_form.update_diary
      redirect_to diary_path(@diary.id), success: t('.success')
    else
      flash.now[:danger] = t('.fail')
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_diary
    @diary = current_user.diaries.find(params[:id])
  end

  def diary_form_params #フォームで入力された情報がパラメータに格納されている
    params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
  end
end

日記新規作成のビュー:

new.html.erb
<%= render 'form', diary_form: @diary_form, form_url: diaries_path, form_method: :post %>

日記編集のビュー:

edit.html.erb
<%= render 'form', diary_form: @diary_form, form_url: diary_path(@diary), form_method: :patch %>

部分テンプレート:

_form.html.erb
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
  <%= render 'shared/error_messages', object: diary_form %>
  <% total_entries.times do |index| %>
    <% if diary_form.entries_ids.present? %>
      <%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
	  <% end %>
    <%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
  <% end %>
  <%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>

ヘルパー:

diaries_helper.rb
module DiariesHelper
  def total_entries
    3
  end
end

3.1 日記作成

3.1.1 newアクション

ユーザーが新規作成ページにアクセスすると、DiariesControllerのnewアクションが呼ばれます。
newアクションではDiaryFormの新しいインスタンスが作成され、@diary_form 変数に代入されます。

diaries_controller.rb
class DiariesController < ApplicationController
  before_action :set_diary, only: [:show, :edit, :update, :destroy]

  def new
    @diary_form = DiaryForm.new
  end
  #省略
end

上記のコードが実行されると、 class DiaryForm内のinitializeメソッドが呼び出されます。
newアクションのDiaryForm.newでは引数を渡していないので、デフォルトのattributes = nil と diary: Diary.newが使用されます。
attributesがnilなので、default_attributesメソッドが呼び出されます。

diary_form.rb
class DiaryForm
  include ActiveModel::Model

  attr_accessor :user_id, :entries_contents, :entries_ids

  def initialize(attributes = nil, diary: Diary.new)
    @diary = diary
    attributes ||= default_attributes
    binding.pry
    super(attributes)
  end
  
  #省略
  
  private

  attr_reader :diary

  def default_attributes
    {
      user_id: diary.user_id,
      entries_contents: diary.diary_entries.map(&:content),
      entries_ids: diary.diary_entries.map(&:id)
    }
  end
#省略
end

この段階では、日記はまだ作成されていなないため、default_attributesメソッドは以下のようなハッシュを返すことになります。

#default_attributesの返り値
{
  user_id: nil,
  entries_contents: [],
  entries_ids: []
}

newアクションの処理が終わると、日記新規作成ページが表示されます。

new.html.erb
#form_witnに渡す、urlとmethodをform_urlとform_method変数に代入
<%= render 'form', diary_form: @diary_form, form_url: diaries_path, form_method: :post %>

diary_form: @diary_formは、コントローラーで定義された@diary_formインスタンス変数を、diary_formという名前でフォームのパーシャルに渡しています。

今回は、パーシャルに渡すローカル変数としてdiary_form以外に以下の2つも渡していきます。form_urlとform_methodというローカル変数をパーシャルに渡すことで、フォームが新規作成(create)なのか更新(update)なのかを区別しています。

  • form_url:
    これはフォームがデータを送信する先のURLです。diaries_path は新しい日記のデータをアプリケーションに送信するためのパスを生成するヘルパーメソッドです。

  • form_method:
    これはフォームがデータをサーバーにどのHTTPメソッドで送信するかを指定します。今回は新規作成(create)なので:postを指定しています。

_form.html.erb
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
  <%= render 'shared/error_messages', object: diary_form %>
  <% total_entries.times do |index| %>
    <% if diary_form.entries_ids.present? %>
      <%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
    <% end %>
    <%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
  <% end %>
  <%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>

フォームの表示:
次に、_form.html.erb パーシャル内で form_with ヘルパーを使ってフォームを生成します。@diary_formオブジェクトをモデルとして利用します。そして先ほど new.html.erb から渡された form_url と form_method がフォームの送信先のURLと使用するHTTPメソッドを設定します。

データの送信:
ユーザーがフォームに入力し、'登録する'ボタンをクリックすると、フォームのデータはcreateアクションに送信されます。

3.1.2 createアクション

diaries_controller
class DiariesController < ApplicationController
  before_action :set_diary, only: [:show, :edit, :update, :destroy]

 #省略

  def create
    #フォームから送信されたパラメータ(diary_form_params)にユーザーidをマージします。
    @diary_form = DiaryForm.new(diary_form_params.merge(user_id: current_user.id))

    if @diary_form.save
      redirect_to diaries_path, success: t('.success')
    else
      flash.now[:danger] = t('.fail')
      render :new, status: :unprocessable_entity
    end
  end

  #省略

DiaryForm.newでinitializeメソッドが呼び出されます。

  • 引数には、ユーザーがフォームに入力した情報(diary_form_params)に現在ログインしているユーザーのIDを追加したものがattributes渡されます。 なので、今回の場合attributesはnilではないので、default_attributesメソッドは呼び出されません。
  • diary:の引数は指定されていないので、デフォルトのdiary: Diary.newが使用されます。

例えば:
ユーザーid:2のユーザーが、日記作成のフォームに、"AAA"、"BBB"、"CCC"の3つを入力した場合、attributesは以下のようになります。

[2] pry(#<DiaryForm>)> attributes
=> #<ActionController::Parameters {"entries_contents"=>["AAA", "BBB", "CCC"], "user_id"=>2}

次に、コントローラでsaveメソッドが呼ばれると、DiaryForm内で定義されたsaveメソッドが実行され、データベースへの保存が試みられます。
保存が成功すると、ユーザーは日記リストページにリダイレクトされます。失敗すると、エラーメッセージが表示され、新規作成ページが再表示されます。

diary_form.rb
class DiaryForm
  include ActiveModel::Model

  attr_accessor :user_id, :entries_contents, :entries_ids

  def initialize(attributes = nil, diary: Diary.new)
    @diary = diary
    attributes ||= default_attributes
    binding.pry
    super(attributes)
  end

  validate :all_entries_must_be_present

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      #ログイン中のユーザーのidと作成した日付を保存
      diary = Diary.create!(user_id:, date: Time.current.to_date)
      #フォームで入力された3つの内容をeachメソッドを使用して一つずつ、diary_entriesテーブルに保存していきます。
      entries_contents.each do |content|
        diary.diary_entries.create!(content:) 
      end
    end
  rescue ActiveRecord::RecordInvalid => e
    e.record.errors.full_messages.each do |message|
      errors.add(:base, message)
    end
    false
  end
  
  #省略
  

3.2 日記編集

3.2.1 editアクション

編集ページへのアクセス:
ユーザーが日記の編集ページにアクセスすると、editアクション前にbefore_action :set_diaryの部分で、編集する日記のデータを取得し、@diaryインスタンス変数に代入されます。
そしてeditアクションが呼ばれます。

class DiariesController < ApplicationController
  before_action :set_diary, only: [:show, :edit, :update, :destroy]

  #省略

  def edit
    @diary_form = DiaryForm.new(diary: @diary)
  end

  #省略

  private

  def set_diary
    @diary = current_user.diaries.find(params[:id])
  end

  def diary_form_params #フォームで入力された情報がパラメータに格納されている
    params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
  end
end

editアクションでDiaryForm.newをしinitializeメソッドが呼び出されます。
今回は引数としてset_diaryメソッド内の@diaryを渡しています。
そうすることで、default_attributesメソッドでデフォルトのフォーム属性が自動的に設定されます。このメソッドは@diaryからユーザーID、日記の内容、そしてそれらの日記の内容に対応するIDを取得し、DiaryFormオブジェクトの属性として割り当てます。これにより、フォームは編集を始める時点で既存の日記のデータを持っていることになり、ユーザーが以前に入力した内容を見たり、必要に応じて変更したりすることができるようになります。

例えば:
set_diaryで以下の日記のデータを取得し@diaryに代入したとします。

[2] pry(#<DiariesController>)> @diary
=> #<Diary:0x00000001099c0fe0
 id: 7,
 date: Mon, 30 Oct 2023,
 user_id: 2,
 created_at: Mon, 30 Oct 2023 21:14:37.964134000 JST +09:00,
 updated_at: Mon, 30 Oct 2023 21:14:37.964134000 JST +09:00>

上記のDiaryのid: 7に対応する3つの日記の内容は以下のようになっているとします。

 [2] pry(#<DiaryForm>)> @diary.diary_entries
=> [#<DiaryEntry:0x0000000107ad1c38
  id: 17,
  content: "AAA",
  diary_id: 7,
  created_at: Mon, 30 Oct 2023 21:14:37.971044000 JST +09:00,
  updated_at: Wed, 08 Nov 2023 13:31:35.852307000 JST +09:00>,
 #<DiaryEntry:0x0000000107ad1af8
  id: 18,
  content: "BBB",
  diary_id: 7,
  created_at: Mon, 30 Oct 2023 21:14:37.973529000 JST +09:00,
  updated_at: Wed, 08 Nov 2023 13:31:35.858614000 JST +09:00>,
 #<DiaryEntry:0x0000000107ad1878
  id: 16,
  content: "CCC",
  diary_id: 7,
  created_at: Mon, 30 Oct 2023 21:14:37.967653000 JST +09:00,
  updated_at: Wed, 08 Nov 2023 13:31:35.863195000 JST +09:00>]

default_attributesメソッドでは以下のようなハッシュを返すことになります。

{:user_id=>2, :entries_contents=>["AAA", "BBB", "CCC"], :entries_ids=>[17, 18, 16]}

フォームの表示:

edit.html.erb
<%= render 'form', diary_form: @diary_form, form_url: diary_path(@diary), form_method: :patch %>
  • form_url: diary_path(@diary)form_method: :patchをパーシャルに渡し日記の更新(update)として処理されるようにしています。

日記の新規作成の時と同じ_form.html.erbパーシャルが呼び出され、@diary_formに編集する日記のデータが渡されます。

_form.html.erb
<%= form_with model: diary_form, url: form_url, method: form_method, class: "lg:w-4/5", data: { turbo: false } do |f| %>
  <%= render 'shared/error_messages', object: diary_form %>
  <% total_entries.times do |index| %>
    <% if diary_form.entries_ids.present? %>
      <%= f.hidden_field :entries_ids, multiple: true, value: diary_form.entries_ids[index] %>
	  <% end %>
    <%= f.text_area :entries_contents, size: "80x2", multiple: true, value: diary_form.entries_contents[index], placeholder:"良かったことを入力"%>
  <% end %>
  <%= f.submit form_method == :post ? '登録する' : '編集する'%>
<% end %>

データの送信:
ユーザーがフォームを編集し、'編集する'ボタンをクリックすると、フォームのデータはupdateアクションに送信されます。

3.2.2 updateアクション

diaries_controller.rb
class DiariesController < ApplicationController
  before_action :set_diary, only: [:show, :edit, :update, :destroy]

  #省略
  
  def update
    @diary_form = DiaryForm.new(diary_form_params, diary: @diary)
    if @diary_form.update_diary
      redirect_to diary_path(@diary.id), success: t('.success')
    else
      flash.now[:danger] = t('.fail')
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_diary
    @diary = current_user.diaries.find(params[:id])
  end

  def diary_form_params #フォームで入力された情報がパラメータに格納されている
    params.require(:diary_form).permit(entries_contents: [], entries_ids: [])
  end
end

DiaryForm.newを使ってDiaryFormのインスタンスを作成します。
この際、initializeメソッドが呼び出され、フォームから送信されたパラメータdiary_form_paramsと、編集対象の日記オブジェクト@diaryが引数として渡されます。
diary_form_paramsにはユーザーがフォームに入力した内容が含まれており、これによりDiaryFormオブジェクトはフォームの入力値を属性として持つことになります。

diary_form.rb
class DiaryForm
 #省略
 
  def initialize(attributes = nil, diary: Diary.new)
    @diary = diary
    attributes ||= default_attributes
    super(attributes)
  end

  validate :all_entries_must_be_present
    #省略

  def update_diary
    return false unless valid?

    ActiveRecord::Base.transaction do
      entries_ids.each_with_index do |id, index|
        entry = DiaryEntry.find(id)
        entry.update!(content: entries_contents[index])
      end
    end

    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  private

  attr_reader :diary #上記の@diaryインスタンス変数に読み取り専用でアクセスするために使うことができる

  #default_attributesメソッド内でattr_reader :diaryが呼び出されています。
  def default_attributes
    {
      user_id: diary.user_id,
      entries_contents: diary.diary_entries.map(&:content),
      entries_ids: diary.diary_entries.map(&:id)
    }
  end

  def all_entries_must_be_present
    errors.add(:base, 'すべてのフォームに入力してください') if entries_contents.any?(&:blank?)
  end
end

次にDiaryForm内のupdate_diaryメソッドを呼び出します。

  • バリデーション:
    作成されたDiaryFormオブジェクトは、valid?メソッドを使ってバリデーションを実行します。このメソッドは、フォームの入力がすべてのバリデーション条件を満たしているかどうかをチェックします。もしバリデーションに失敗すると、処理はそこで中断され、エラーメッセージと共に編集フォームが再表示されます。

  • データの更新:
    トランザクション内で、entries_idsに含まれる各IDに対応する日記のコンテンツをDiaryEntry.find(id)で探しし、update!メソッドを使ってその内容(content)をentries_contents配列から取得した新しい内容で更新します。update!メソッドは、更新が成功するか、例外が発生するかのどちらかです。例外が発生した場合、トランザクションはロールバックされ、エラーメッセージが返されます。

これで、日記作成と日記編集のができるようになりました。

今回はデータの一括保存で、form objectを使用しましたがコレクションモデルを使用してデータの一括保存を実装している記事もありました。見た感じform objectと似たような感じがしてまだこの二つの違いがよく分かっていません:sweat_smile:
記事も、古いものが多かったので今回はform objectを使用しました。
データを一括保存で保存する他の方法を知っている方がいましたら、教えてくださると助かります。:bow_tone1:

おわりに

今回初めて記事を書いたのですが、思った以上に大変でした。頭では分かっていても、いざ言葉で説明するとなるとうまく語源化することができず苦戦しました。
苦戦した分、少し苦手意識のあったform objectについて理解を深めることができたと思います。

最後まで読んでいただきありがとうございました。

19
18
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
19
18