概要
「simple_calendar」 という gem を使ってシンプルなカレンダー機能を Rails アプリに実装してみます。
完成イメージ
仕様
- Ruby 3
- Rails 6
- MySQL 5.7
- Bootstrap 5
- Docker
※ Bootstrap はバージョン5から JQuery が不要になり導入方法が少し変わったのでご注意ください。
下準備
まず最初に下準備から始めていきます。
各種ディレクトリ & ファイルを作成
$ mkdir rails-simple-calendar-app && cd rails-simple-calendar-app
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
FROM ruby:3.0
RUN curl https://deb.nodesource.com/setup_14.x | bash
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn
ENV APP_PATH /myapp
RUN mkdir $APP_PATH
WORKDIR $APP_PATH
COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install
COPY . $APP_PATH
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
version: "3"
services:
db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: password
volumes:
- mysql-data:/var/lib/mysql
- /tmp/dockerdir:/etc/mysql/conf.d/
ports:
- 4306:3306
web:
build:
context: .
dockerfile: Dockerfile
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/myapp
- ./vendor/bundle:/myapp/vendor/bundle
environment:
TZ: Asia/Tokyo
RAILS_ENV: development
ports:
- "3000:3000"
depends_on:
- db
volumes:
mysql-data:
#!/bin/bash
set -e
# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid
# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄でOK
rails new
おなじみのコマンドでアプリの雛型を作成。
$ docker-compose run web rails new . --force --no-deps -d mysql --skip-test
database.ymlを編集
デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。
default: &default
adapter: mysql2
encoding: utf8mb4
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root
password: password
host: db
development:
<<: *default
database: myapp_development
test:
<<: *default
database: myapp_test
production:
<<: *default
database: <%= ENV["DATABASE_NAME"] %>
username: <%= ENV["DATABASE_USERNAME"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>
host: <%= ENV["DATABASE_HOST"] %>
コンテナを起動 & データベースを作成
$ docker-compose build
$ docker-compose up -d
$ docker-compose run web bundle exec rails db:create
動作確認(localhost:3000 にアクセス)
localhost:3000 にアクセスしてウェルカムページが表示されればOKです。
slim を導入
個人的にビューのテンプレートエンジンは erb よりも slim の方が好みなので変更します。
gem 'slim-rails'
gem 'html2slim'
$ docker-compose build
既存のビューファイルを slim に書き換え。
$ docker-compose run web bundle exec erb2slim app/views app/views
$ docker-compose run web bundle exec erb2slim app/views app/views -d
Bootstrap を導入
見た目を整えるために Bootstrap を使用します。
※ 下記の手順はバージョン5を想定したものになるので、別のバージョンを使いたい場合は他の記事を参考に導入してください。
yarn add
必要なライブラリをインストール。
$ docker-compose run web yarn add bootstrap @popperjs/core
app/views/layouts/application.html
- = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
+ = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
「app/views/layouts/application.html」内の9行目あたりに記述されている「stylesheet_link_tag」を「stylesheet_pack_tag」に書き換えます。
app/javascript/stylesheets/application.scss
$ mkdir -p app/javascript/stylesheets
$ touch app/javascript/stylesheets/application.scss
「app/javascript/stylesheets/」以下に「application.scss」を作成し、次の1行を追記してください。
@import "~bootstrap/scss/bootstrap";
app/javascript/packs/application.js
「app/javascript/packs/application.js」内に次の2行を追記。
import "bootstrap";
import "../stylesheets/application";
これで Bootstrap の設定は完了です。
Font Awesome を導入
各種アイコンを使うために Font Awesome を導入します。
$ docker-compose run web yarn add @fortawesome/fontawesome-free
// 追記
import "@fortawesome/fontawesome-free/js/all";
これだけで OK です。
本実装
準備ができたので、本格的な実装に入りましょう。
simple_calendar をインストール
冒頭でも触れた「simple_calendar」 という gem をインストールします。
gem 'simple_calendar'
$ docker-compose build
Event モデルを作成
Event |
---|
title: String |
start_time: DateTime |
end_time: DateTime |
今回は
- title
- start_time
- end_time
という3つの属性を持ったシンプルな Event モデルを作成。
中でもポイントは start_time
で、simple_calendar はデフォルトの状態だとこの値を基にどの日付なのかを判断してカレンダーに紐付けるようになっています。
$ docker-compose run web rails g model Event title:string start_time:datetime end_time:datetime
$ docker-compose run web rails db:migrate
class CreateEvents < ActiveRecord::Migration[6.1]
def change
create_table :events do |t|
t.string :title, null: false
t.datetime :start_time, null: false
t.datetime :end_time, null: false
t.timestamps
end
end
end
class Event < ApplicationRecord
validates :title, presence: true
validates :start_time, presence: true
validates :end_time, presence: true
end
念のためバリデーションも設定。
コントローラーを作成
$ docker-compose run web rails g controller events
class EventsController < ApplicationController
# フラッシュメッセージを Bootstrap 対応させるための設定
add_flash_types :success, :info, :warning, :danger
before_action :set_event, only: %i[edit update destroy]
def index
@events = Event.all
end
def new
@event = Event.new
@default_date = params[:default_date].to_date
end
def create
event = Event.new(event_params)
if event.save!
redirect_to events_path, success: "イベントの登録に成功しました"
else
render :new
end
end
def edit; end
def update
if @event.update!(event_params)
redirect_to events_path, success: "イベントの更新に成功しました"
else
render :edit
end
end
def destroy
@event.destroy
redirect_to events_path, success: "イベントの削除に成功しました"
end
private
def event_params
params.require(:event).permit(:title, :start_time, :end_time)
end
def set_event
@event = Event.find(params[:id])
end
end
ビューを作成
$ touch app/views/events/index.html.slim app/views/events/_form.html.slim app/views/events/new.js.erb app/views/events/edit.js.erb
// フラッシュメッセージ
- flash.each do |type, message|
div class="alert alert-#{type} alert-dismissible rounded-0 fade show" role="alert"
button.btn-close aria-label="Close" data-bs-dismiss="alert" type="button"
h5.m-0
= message
.container
.row.p-3
= month_calendar(events: @events) do |date, events|
.d-flex.align-items-center.mb-3
= date.day
.ms-auto
= link_to new_event_path(default_date: date), { class: "btn btn-sm btn-outline-primary fs10", "data-bs-target": "#eventModal", "data-bs-toggle": "modal", remote: true } do
i.fas.fa-plus
- events&.each do |event|
.my-1
= link_to "#{event.title}(#{event.start_time.strftime("%R")} ~ #{event.end_time.strftime("%R")})", edit_event_path(event), { class: "btn btn-sm btn-success", "data-bs-target": "#eventModal", "data-bs-toggle": "modal", remote: true }
// モーダル画面
#eventModal.modal.fade aria-hidden="true" aria-labelledby="eventModalLabel" tabindex="-1"
.modal-dialog style="max-width:32%;"
.modal-content
.modal-header
button.btn-close id="modalClose" aria-label="Close" data-bs-dismiss="modal" type="button"
.modal-body
#form
= form_with model: event, url: url, local: false do |f|
table.table.table-borderless
tr
td = f.label t("activerecord.attributes.event.title"), class: "control-label"
tr
td = f.text_field :title, class: "form-control"
tr
td = f.label t("activerecord.attributes.event.start_time"), class: "control-label"
tr
td
.datetime_select
= raw sprintf(f.datetime_select(:start_time, with_css_classes: true, default: default_date, use_month_numbers: true, start_year: (Time.current.year), end_year: (Time.current.year + 1), date_separator: '%s', datetime_separator: "%s", time_separator: "%s"), "年 ", "月 ", "日 ", "時 ") + "分"
tr
td = f.label t("activerecord.attributes.event.end_time"), class: "control-label"
tr
td
.datetime_select
= raw sprintf(f.datetime_select(:end_time, with_css_classes: true, default: default_date, use_month_numbers: true, start_year: (Time.current.year), end_year: (Time.current.year + 1), date_separator: '%s', datetime_separator: "%s", time_separator: "%s"), "年 ", "月 ", "日 ", "時 ") + "分"
.d-flex.justify-content-end
- if action_name == "edit"
= link_to "削除", event_path(event), method: :delete, data: { confirm: "本当に削除しますか?" }, class: "btn btn-danger me-1"
= f.submit class: "btn btn-success"
document.getElementById("form").innerHTML = "<%= j(render 'form', event: @event, url: events_path, default_date: @default_date) %>";
document.getElementById("form").innerHTML = "<%= j(render 'form', event: @event, url: event_path, default_date: nil) %>";
デフォルトのデザインだとやや地味なので、カスタマイズ用のビューを準備します。
$ docker-compose run web rails g simple_calendar:views
上記のコマンドを実行すると app/views
配下に自動で次のようなビューファイルが生成されるはず。
<div class="simple-calendar">
<div class="calendar-heading">
<%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
<span class="calendar-title"><%= t('date.month_names')[start_date.month] %> <%= start_date.year %></span>
<%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
</div>
<table class="table table-striped">
<thead>
<tr>
<% date_range.slice(0, 7).each do |day| %>
<th><%= t('date.abbr_day_names')[day.wday] %></th>
<% end %>
</tr>
</thead>
<tbody>
<% date_range.each_slice(7) do |week| %>
<%= content_tag :tr, class: calendar.tr_classes_for(week) do %>
<% week.each do |day| %>
<%= content_tag :td, class: calendar.td_classes_for(day) do %>
<% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
<% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
<% else %>
<% passed_block.call day, sorted_events.fetch(day, []) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</div>
<div class="simple-calendar">
<div class="calendar-heading">
<%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
<span class="calendar-title"><%= t('date.month_names')[start_date.month] %> <%= start_date.year %></span>
<%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
</div>
<table class="table table-striped">
<thead>
<tr>
<% date_range.slice(0, 7).each do |day| %>
<th><%= t('date.abbr_day_names')[day.wday] %></th>
<% end %>
</tr>
</thead>
<tbody>
<% date_range.each_slice(7) do |week| %>
<tr>
<% week.each do |day| %>
<%= content_tag :td, class: calendar.td_classes_for(day) do %>
<% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
<% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
<% else %>
<% passed_block.call day, sorted_events.fetch(day, []) %>
<% end %>
<% end %>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="simple-calendar">
<div class="calendar-heading">
<%= link_to t('simple_calendar.previous', default: 'Previous'), calendar.url_for_previous_view %>
<% if calendar.number_of_weeks == 1 %>
<span class="calendar-title"><%= t('simple_calendar.week', default: 'Week') %> <%= calendar.week_number %></span>
<% else %>
<span class="calendar-title"><%= t('simple_calendar.week', default: 'Week') %> <%= calendar.week_number %> - <%= calendar.end_week %></span>
<% end %>
<%= link_to t('simple_calendar.next', default: 'Next'), calendar.url_for_next_view %>
</div>
<table class="table table-striped">
<thead>
<tr>
<% date_range.slice(0, 7).each do |day| %>
<th><%= t('date.abbr_day_names')[day.wday] %></th>
<% end %>
</tr>
</thead>
<tbody>
<% date_range.each_slice(7) do |week| %>
<tr>
<% week.each do |day| %>
<%= content_tag :td, class: calendar.td_classes_for(day) do %>
<% if defined?(Haml) && respond_to?(:block_is_haml?) && block_is_haml?(passed_block) %>
<% capture_haml(day, sorted_events.fetch(day, []), &passed_block) %>
<% else %>
<% passed_block.call day, sorted_events.fetch(day, []) %>
<% end %>
<% end %>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
あとは CSS で調整していきましょう。
// 追記
.simple-calendar {
table {
-webkit-border-horizontal-spacing: 0px;
-webkit-border-vertical-spacing: 0px;
background-color: rgba(0, 0, 0, 0);
border: 1px solid rgb(221, 221, 221);
border-collapse: collapse;
box-sizing: border-box;
max-width: 100%;
width: 100%;
}
tr {
border-collapse: collapse;
}
th {
padding: 6px;
border-bottom: 2px solid rgb(221, 221, 221);
border-collapse: collapse;
border-left: 1px solid rgb(221, 221, 221);
border-right: 1px solid rgb(221, 221, 221);
border-top: 0px none rgb(51, 51, 51);
box-sizing: border-box;
text-align: left;
}
td {
padding: 6px;
vertical-align: top;
width: 14%;
border: 1px solid #ddd;
border-top-color: rgb(221, 221, 221);
border-top-style: solid;
border-top-width: 1px;
border-right-color: rgb(221, 221, 221);
border-right-style: solid;
border-right-width: 1px;
border-bottom-color: rgb(221, 221, 221);
border-bottom-style: solid;
border-bottom-width: 1px;
border-left-color: rgb(221, 221, 221);
border-left-style: solid;
border-left-width: 1px;
}
.day {
height: 80px;
}
.today {
background: #ffffc0;
}
.prev-month {
background: #ddd;
}
.next-month {
background: #ddd;
}
}
.datetime_select {
.year,
.month,
.day,
.hour,
.minute {
display: inline-block;
width: auto;
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
}
.fs10 {
font-size: 10px;
}
ルーティングを設定
Rails.application.routes.draw do
resources :events, except: %i[show]
end
日本語化対応
最後に「config/application.rb」内に以下を内容を記述し、デフォルトのタイムゾーンや言語を日本に変更してください。
module Myapp
class Application < Rails::Application
# ...
# 追記
config.time_zone = "Tokyo"
config.active_record.default_timezone = :local
config.i18n.default_locale = :ja
config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}").to_s]
# ...
end
end
また、「config/locals/」以下にja.ymlを作成します。
$ touch config/locales/ja.yml
ja:
activerecord:
models:
event: イベント
attributes:
event:
id: ID
title: タイトル
start_time: 開始時刻
end_time: 終了時刻
created_at: 登録日
updated_at: 更新日
date:
formats:
default: "%Y/%m/%d"
short: "%m/%d"
long: "%Y年%m月%d日(%a)"
day_names: [日曜日, 月曜日, 火曜日, 水曜日, 木曜日, 金曜日, 土曜日]
abbr_day_names: [日, 月, 火, 水, 木, 金, 土]
month_names:
[~, 1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]
abbr_month_names:
[~, 1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]
order:
- :year
- :month
- :day
time:
formats:
default: "%Y/%m/%d %H:%M:%S"
short: "%y/%m/%d %H:%M"
long: "%Y年%m月%d日(%a) %H時%M分%S秒 %Z"
am: "午前"
pm: "午後"
simple_calendar:
previous: 前月
next: 次月
helpers:
submit:
create: 登録する
update: 更新する
submit: 保存する
動作確認
localhost:3000/events にアクセスしてこんな感じになっていれば完成です。
あとはちゃんと CRUD 機能が動いているか動作確認してください。
備考
- simple_calendar を使うためには「start_time」という名前の値が必要となるが、既存のデータに存在しない場合や別の値を使いたい場合は
attribute
という引数に任意の値を渡す事で代用可能。
あとがき
以上、「simple_calendar」という gem で文字通りシンプルなカレンダー機能を実装してみました。
公式ドキュメントを見た感じだと他にも色々カスタマイズの余地がありそうだったので、気になる方はいじってみてください。