20200812のRailsに関する記事は15件です。

【⭐️オススメ便利機能⭐️】~インデントを色で識別!?~

【概要】

1.オススメ便利機能とは

2.なぜ入れた方がいいの?

3.どのように使うのか⁉︎

4.導入してわかったこと

1.オススメ便利機能とは

その名も”indent-rainbow

今すぐ、入れるべき‼︎

2.なぜ入れた方がいいの?

下記の画像を見てください!
インデントが列によって、
レインボーに色別されています!

スクリーンショット 2020-08-12 22.45.26.png

これにより、どの開閉タグ(ex:divでの表示ミス)
がどこに対応しているのか一発でわかります!

またタブではなくスペースキーで1文字開けた時は、
赤くなり教えてくるのも大変便利です!

3.どのように入れて、使うの⁉︎

Vscodeのスクリーンショット 2020-08-12 23.12.22.png
から”indent-rainbow”と検索するだけ!
あとはインストールを押すだけ!

あとは緑のインストールボタン押すだけ!
あとは視覚化されるので簡単です!

4.導入してわかったこと

I)実は列がレインボーになるだけと思っていました笑
 たまたまスペースを押したら赤くなったのでさらに
 便利だなと感じました!

II)divで表示ミスをすることが頻繁にあったので
 列を見易くすることでミスに気付けました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

wwwなしの、SSL(https)にリダイレクトしたいときのNGINXの設定 -Route 53 + ELB (ACM) + EC2構成-

全てのトラフィックをwwwなしの、SSL(https)にリダイレクトしたい

結論

長いので先に結論だけ書きます

  • SSL証明にACMを使い、ELBに証明書が適応されている状態を想定

  • ELB <-> EC2の接続がHTTP: 80の場合、NGINXはport 80でlistenする (ここの気付きが重要でした)

  • NGINXによるリダイレクトはwwwあり->wwwなしのみでよい

  • httpsリクエストをNGINXの80で受けるという設定に戸惑ったが、動作上これで良さそう

現状と目標

httpsかつ、wwwなしのドメインをリクエストしないとSSL接続されない

理想は以下の全ての条件でSSL接続にリダイレクトしたいが現状は

# wwwあり
http://www.mdclip.xyz -> http://mdclip.xyz  (no SSL)
https://www.mdclip.xyz ->  http://mdclip.xyz (no SSL)

# wwwなし
http://mdclip.xyz -> http://mdclip.xyz (no SSL)
https://mdclip.xyz -> https://mdclip.xyz (SSL)

環境

  • DNS (Route 53) -> ELB -> EC2 (NGINX -> Puma)
  • SSL証明: ACM
  • Rails 6 (config/environments/production.rb: config.force_ssl = true)

NGINXの設定はnginx.confに書く?conf.d/*.confに書く

NGINXの設定はnginx.confに記述されているが
その中で/etc/nginx/conf.d/*.confがincludeされており
conf.d以下の内容が呼び出されるようになっている

記事によってこの辺りが曖昧でどのように使い分けるか把握しにくかったが
以下の記事をもとにイメージを掴むことができた

参考: nginx連載3回目: nginxの設定、その1 - インフラエンジニアway - Powered by HEARTBEATS

NGINXではserverディレクティブ(サーバー個別の設定、server {...}で記述される)で定義された
仮想サーバーを複数稼働させることができる
そして、このバーチャルサーバー毎の設定を置く場所がconf.d以下とのこと

なのでconf.d以下のfoo.confには単一のサーバーについての記述に留め
複数のserverディレクティブが記述されているような運用は良くないのかもしれない

ただし、今回conf.d以下の設定ファイルにドメイン間のリダイレクトを担う
serverディレクティブを複数配置してみたが、動作上は問題なさそうでした

全てのトラフィックをwwwなしの、SSL(https://)にリダイレクトしたい場合のNGINXの設定

結論

後述の内容から、以下のように書けば良いと理解した

server {
    listen 80; 
    server_name example.com;
    return 301 https://example.com$request_uri;
}
server {
    listen 80;
    listen 443 ssl; 
    server_name www.example.com; 
    return 301 https://example.com$request_uri; 
}

もしくは

server {
    listen 80; 
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}
server {
    listen 443 ssl; 
    server_name www.example.com; 
    return 301 https://example.com$request_uri; 
}

wwwなし -> wwwあり(その逆パターン)

# add 'www'
server {
    listen 80; #非SSL
    listen 443 ssl; #SSL こうやって2つ指定できる
    server_name example.com; #wwwなしを
    return 301 $scheme://www.example.com$request_uri; #wwwありへ
}

# 上記と同意
server {
    listen 80; #非SSL
    server_name example.com;
    return 301 $scheme://www.example.com$request_uri;
}

server {
    listen 443 ssl; #SSL 
    server_name example.com;
    return 301 $scheme://www.example.com$request_uri;
}


# remove 'www'
server {
    listen 80; #非SSL
    listen 443 ssl; #SSL 
    server_name www.example.com; #wwwありを
    return 301 $scheme://example.com$request_uri; #wwwなしへ
}

参考: How to Create NGINX Rewrite Rules | NGINX

Adding and Removing the www Prefix

These examples add and remove the www prefix:

# add 'www'
server {
listen 80;
listen 443 ssl;
server_name domain.com;
return 301 $scheme://www.domain.com$request_uri;
}

# remove 'www'
server {
listen 80;
listen 443 ssl;
server_name www.domain.com;
return 301 $scheme://domain.com$request_uri;
}

Again, return is preferable to the equivalent rewrite, which follows. The rewrite requires interpreting a regular expression – ^(.*)$ – and creating a custom variable ($1) that in fact is equivalent to the built‑in $request_uri variable.

# NOT RECOMMENDED
rewrite ^(.*)$ $scheme://www.domain.com$1 permanent;

http(非SSL) -> https(SSL)にリダイレクト

# http(非SSL) -> https(SSL)に転送
server {
    listen 80; 
    server_name www.example.com;
    return 301 https://www.example.com$request_uri;
}

古いドメインから新しいドメインに移行する場合などは
敢えて$request_uriを省略することでホームページへ都度リダイレクトすることも可能

参考: How to Create NGINX Rewrite Rules | NGINX

Example – Forcing all Requests to Use SSL/TLS

This server block forces all visitors to use a secured (SSL/TLS) connection to your site.

server {
 listen 80;
 server_name www.domain.com;
 return 301 https://www.domain.com$request_uri;
}

Some other blogs about NGINX rewrite rules use an if test and the rewrite directive for this use case, like this:

# NOT RECOMMENDED
if ($scheme != "https") {
 rewrite ^ https://www.mydomain.com$uri permanent;
}

But this method takes extra processing because NGINX must both evaluate the if condition and process the regular expression in the rewrite directive.

全てのトラフィックを特定のドメインにリダイレクト

server {
    listen 80 default_server; # http://
    listen 443 ssl default_server; # https://
    server_name _; # "_"全一致
    return 301 $scheme://www.example.com;
}

参考: How to Create NGINX Rewrite Rules | NGINX

Redirecting All Traffic to the Correct Domain Name

Here’s a special case that redirects incoming traffic to the website’s home page when the request URL doesn’t match any server and location blocks, perhaps because the domain name is misspelled. It works by combining the default_server parameter to the listen directive and the underscore as the parameter to the server_name directive.

server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 301 $scheme://www.domain.com;
}

We use the underscore as the parameter to server_name to avoid inadvertently matching a real domain name – it’s safe to assume that no site will ever have the underscore as its domain name. Requests that don’t match any other server blocks in the configuration end up here, though, and the default_server parameter to listen tells NGINX to use this block for them. By omitting the $request_uri variable from the rewritten URL, we redirect all requests to the home page, a good idea because requests with the wrong domain name are particularly likely to use URIs that don’t exist on the website.

rewriteよりもreturnを用いることが望ましい理由

  • if...処理を都度行うことは効率的でない.
  • rewriteを用いるとNGINXが正規表現を処理する必要があり効率的でない.
  • ヒトにとってもNGINXが301コードを返すことが明示的で解釈しやすい.

以上を踏まえたNGINXの設定(NG)

nginx.conf
いずれのバーチャルサーバーにも該当しない場合のトラフィックを受ける
default_serverについての記述のみ残しました

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 2048;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    include /etc/nginx/conf.d/*.conf;

    server {
        listen       80 default_server;
        listen       [::]:80 default_server;
        server_name  _;
        root         /usr/share/nginx/html;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }
}

conf.d/app.conf

error_log  /var/www/myapp/shared/log/nginx.error.log;
access_log /var/www/myapp/shared/log/nginx.access.log;

upstream myapp {
    server unix://var/www/myapp/shared/tmp/sockets/puma.sock;
}

## #1 以下の2つのserverディレクティブで, wwwなしのhttpsにリダイレクト
# http > https
server {
    listen 80;
    server_name example.com;
    return 301 https://example.com$request_uri;
}
# www > no_www
server {
    listen 80;
    listen 443 ssl;
    server_name www.example.com;
    return 301 https://example.com$request_uri;
}


server {
    listen 443 ssl; # #2 httpsで受ける
    client_max_body_size 4G;
    server_name example.com;

    keepalive_timeout 5;

    # Location of our static files
    root /var/www/myapp/current/public;

    location ~ ^/assets/ {
    root /var/www/myapp/current/public;
    }

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;

        if (!-f $request_filename) {
            proxy_pass http://myapp;
            break;
        }
    }

    error_page 500 502 503 504 /500.html;
    location = /500.html {
        root /var/www/myapp/current/public;
    }
}

ところがこの時点での結果は、

# wwwあり
http://www.mdclip.xyz -> http://www.mdclip.xyz  (no SSL)
https://www.mdclip.xyz ->  https://www.mdclip.xyz (SSL)

# wwwなし
http://mdclip.xyz -> http://mdclip.xyz (no SSL)
https://mdclip.xyz -> https://mdclip.xyz (SSL)

スクリーンショット 2020-08-12 22.19.34.png

しかも全てのトラフィックがPumaではなくdefault_serverに到達してしまった

以下再度検証へ...

最終検証・解決

AWS Route 53

example.comだけでなく、www.example.comのAレコードについても
ルーティング先にELBのエイリアスを指定する必要がありました

これがないとWWWありのリクエストがNGINXまで到達せず

”サーバーが見つからない”エラーが出ます

ELBの設定を確認

スクリーンショット 2020-08-12 21.07.01.png

Target groupの設定はEC2インスタンスにHTTP: 80で接続するようになっていました
(ここもHTTPS: 443に変更する方法もあるようです)

この状況を図にすると
(これがやりたかった!)

traffic_flow.png

重要なのはHTTPSリクエストに対しても
EC2は80ポートでELBと接続されているということ

(言葉が不適切でしたら訂正願います)

だから

  • NGINX側は443ではなく80でlistenする必要がある
  • 443 > 80へのリダイレクトはNGINXで設定不要(上図の緑の部分のみでいい)

NGINXの設定(再・OK)

nginx.conf
ここは変わりなし

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 2048;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    include /etc/nginx/conf.d/*.conf;

    server {
        listen       80 default_server;
        listen       [::]:80 default_server;
        server_name  _;
        root         /usr/share/nginx/html;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }
}

conf.d/app.conf

error_log  /var/www/myapp/shared/log/nginx.error.log;
access_log /var/www/myapp/shared/log/nginx.access.log;

upstream myapp {
    server unix://var/www/myapp/shared/tmp/sockets/puma.sock;
}

# www > no_www
server {
    listen 80; #443は不要
    server_name www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 80; #80でlisten
    client_max_body_size 4G;
    server_name example.com;

    keepalive_timeout 5;

    # Location of our static files
    root /var/www/myapp/current/public;

    location ~ ^/assets/ {
    root /var/www/myapp/current/public;
    }

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;

        if (!-f $request_filename) {
            proxy_pass http://myapp;
            break;
        }
    }

    error_page 500 502 503 504 /500.html;
    location = /500.html {
        root /var/www/myapp/current/public;
    }
}

これで以下のように目標が達成されました

# wwwあり
http://www.mdclip.xyz -> https://mdclip.xyz  (SSL)
https://www.mdclip.xyz ->  https://mdclip.xyz (SSL)

# wwwなし
http://mdclip.xyz -> https://mdclip.xyz (SSL)
https://mdclip.xyz -> https://mdclip.xyz (SSL)

おまけ

AmazonLinux環境でのNGINX関連コマンド

設定をリロード

sudo systemctl reload nginx

再起動

sudo systemctl restart nginx

起動

sudo systemctl start nginx

終了

sudo systemctl stop nginx

NGINXのプロセスを確認 (残ったプロセスをkillするときなど)

sudo lsof -i | grep nginx
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ruby on RailsのDevise gemを使ってエラーCouldn't find User with 'id'=sign_inが出た時の対処法

発生したエラー内容

ユーザー登録やログイン認証のためにDevise gemを入れて、さらに自分でUser Controllerを作ったら、以下のエラーメッセージが出て、deviseのログイン画面やユーザー登録画面が動かなくなる、ユーザー情報画面が表示されないといったエラーが発生しました。

  • ActiveRecord::RecordNotFound in UsersController#show
  • Couldn't find User with 'id'=sign_in(sign_up)

問題が発生した実行環境

  • Cloud9(Ubuntu)
  • ruby 2.6.3
  • Rails 6.0.3.2
  • Devise 4.7.2

問題の原因

この問題が発生したのは、user/:idというルーティングが、user/sign_inやuser/sign_upを包括してしまっていたためでした。Railsのルーティングは上から順に読み込んでいき、合致するルーティングを探しているため、resources :users が先に読み込まれて、sign_inやsign_upをidだと認識してしまったことが原因だったようです。

                   users GET    /users(.:format)                  users#index
                    user GET    /users/:id(.:format)              users#show
        new_user_session GET    /users/login(.:format)            users/sessions#new
            user_session POST   /users/login(.:format)            users/sessions#create
    destroy_user_session DELETE /users/logout(.:format)           users/sessions#destroy
       new_user_password GET    /users/password/new(.:format)     devise/passwords#new
      edit_user_password GET    /users/password/edit(.:format)    devise/passwords#edit
           user_password PATCH  /users/password(.:format)         devise/passwords#update
                         PUT    /users/password(.:format)         devise/passwords#update
                         POST   /users/password(.:format)         devise/passwords#create
cancel_user_registration GET    /users/cancel(.:format)           users/registrations#cancel
   new_user_registration GET    /users/signup(.:format)           users/registrations#new
  edit_user_registration GET    /users/edit(.:format)             users/registrations#edit
       user_registration PATCH  /users(.:format)                  users/registrations#update
                         PUT    /users(.:format)                  users/registrations#update
                         DELETE /users(.:format)                  users/registrations#destroy
                         POST   /users(.:format)                  users/registrations#create

上のようにuser GET /users/:id(.:format) users#show がDevise Gemのルーティングより上に来てしまっている場合、routes.rbは以下のように記述されているかと思います。

routes.rb
Rails.application.routes.draw do
  resources :users, only: [:index, :show]
  devise_for :users, controllers: {
        sessions: 'users/sessions',
        registrations: 'users/registrations'},
    path_names: {
      sign_in: 'login',
      sign_out: 'logout',
      sign_up: 'signup'
    }
end

解決策

routes.rbの書き方を修正します。具体的には、resources :users, only: :index, :showをdevise for :usersの後ろに移動することで解決します。

routes.rb
Rails.application.routes.draw do
  devise_for :users, controllers: {
        sessions: 'users/sessions',
        registrations: 'users/registrations'},
    path_names: {
      sign_in: 'login',
      sign_out: 'logout',
      sign_up: 'signup'
    }
  resources :users, only: [:index, :show]
end

正しい順番で記載してからrails routesで確認してみると、user GET /users/:id(.:format) users#showよりもDevise gemのルーティングが上に来ていることが確認できるかと思われます(上記コードではpath_namesを使ってsign_inをloginに、sign_outをlogoutに変更していますが、大意に影響はありません)。

        new_user_session GET    /users/login(.:format)            users/sessions#new
            user_session POST   /users/login(.:format)            users/sessions#create
    destroy_user_session DELETE /users/logout(.:format)           users/sessions#destroy
       new_user_password GET    /users/password/new(.:format)     devise/passwords#new
      edit_user_password GET    /users/password/edit(.:format)    devise/passwords#edit
           user_password PATCH  /users/password(.:format)         devise/passwords#update
                         PUT    /users/password(.:format)         devise/passwords#update
                         POST   /users/password(.:format)         devise/passwords#create
cancel_user_registration GET    /users/cancel(.:format)           users/registrations#cancel
   new_user_registration GET    /users/signup(.:format)           users/registrations#new
  edit_user_registration GET    /users/edit(.:format)             users/registrations#edit
       user_registration PATCH  /users(.:format)                  users/registrations#update
                         PUT    /users(.:format)                  users/registrations#update
                         DELETE /users(.:format)                  users/registrations#destroy
                         POST   /users(.:format)                  users/registrations#create
                   users GET    /users(.:format)                  users#index
                    user GET    /users/:id(.:format)              users#show

この方法に直したら問題なくログインできるようになりました。

補足:devise_forメソッドのルーティングを整理してみました

devise_forメソッドを使うと、かなり複雑なルーティングになってしまう。もしdeviseを使わずにresources :usersでルーティングした場合との対応表を作りましたので、合わせてご査収ください。

resource :users devise_for
users#index なし
users#new devise/registration#new
users#create devise/registration#create
users#edit devise/registration#edit
users#show なし
users#update devise/registration#update
users#destroy devise/registration#destroy
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ec2使用時にdockerの容量がいっぱいになった際の対処法

dockerを起動しようとしたら以下のエラーが発生した。

$ docker-compose build
Failed to write all bytes for _codecs_cn.so
fwrite: No space left on device



容量不足により起動不可となっているようだ。
以下のコマンドを叩くと容量を喰っているファイルを見つけることができる。

$ df -h
ファイルシス   サイズ  使用  残り 使用% マウント位置
devtmpfs         474M     0  474M    0% /dev
tmpfs            492M     0  492M    0% /dev/shm
tmpfs            492M   13M  479M    3% /run
tmpfs            492M     0  492M    0% /sys/fs/cgroup
/dev/xvda1       8.0G  8.0G   22M  100% /  //容量不足
tmpfs             99M     0   99M    0% /run/user/1001

/dev/xvda1 というファイルがかなりの容量を占めている。
logなどの不要ファイルを削除しても解決できる場合もあるが、今回はEC2で使用しているEBSのサイズを変更して対応する。
無料利用枠のt2.microでは8GBがデフォルトで、今回は16GBに変更する。(若干料金が発生する:約1$/月)

AWSのEC2に移動し、ボリュームに移動する。
目的のボリュームを選択した状態で アクション→ボリュームの変更→サイズを変更(任意の数字:今回は16)→変更
変更が反映され状態がin-useになったらOK。


あとはターミナルからEC2にログインし、以下のコマンドを順に実行していく。

$ sudo growpart /dev/xvda 1
CHANGED: partition=1 start=4096 old: size=16773087 end=16777183 new: size=33550303 end=33554399

xvdaが16GBに変更されていることが確認できる。

$ lsblk
NAME    MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
xvda    202:0    0  16G  0 disk 
└─xvda1 202:1    0   8G  0 part /

ファイルシステム上は変更されていないので次のコマンドで変更できる。

$ sudo xfs_growfs /dev/xvda1
meta-data=/dev/xvda1             isize=512    agcount=4, agsize=524159 blks
         =                       sectsz=512   attr=2, projid32bit=1
         =                       crc=1        finobt=1 spinodes=0
data     =                       bsize=4096   blocks=2096635, imaxpct=25
         =                       sunit=0      swidth=0 blks
naming   =version 2              bsize=4096   ascii-ci=0 ftype=1
log      =internal               bsize=4096   blocks=2560, version=2
         =                       sectsz=512   sunit=0 blks, lazy-count=1
realtime =none                   extsz=4096   blocks=0, rtextents=0
data blocks changed from 2096635 to 4193787

変更が反映されていたら以下のように容量が8GB→16GBに変更されており、
使用率が低減できていることが確認できる。

$ lsblk
NAME    MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
xvda    202:0    0  16G  0 disk 
└─xvda1 202:1    0  16G  0 part /

$ df -h
ファイルシス   サイズ  使用  残り 使用% マウント位置
devtmpfs         474M     0  474M    0% /dev
tmpfs            492M     0  492M    0% /dev/shm
tmpfs            492M   13M  479M    3% /run
tmpfs            492M     0  492M    0% /sys/fs/cgroup
/dev/xvda1        16G  5.6G   11G   35% /
tmpfs             99M     0   99M    0% /run/user/1001


無事に起動できるようになり、容量にもかなりの余裕ができた。

$ docker-compose build
Building web
:
Successfully built 99f5770ca10a
Successfully tagged app_web:latest

$ df -h
ファイルシス   サイズ  使用  残り 使用% マウント位置
devtmpfs         474M     0  474M    0% /dev
tmpfs            492M     0  492M    0% /dev/shm
tmpfs            492M   14M  479M    3% /run
tmpfs            492M     0  492M    0% /sys/fs/cgroup
/dev/xvda1        16G  8.1G  8.0G   51% /    //容量に余裕ができた
tmpfs             99M     0   99M    0% /run/user/1001


参考記事
https://www.souichi.club/aws/aws-ebs/
CentOS 7からは、xfs_growfsというコマンドを使うことになったようだ。
https://qiita.com/ponsuke0531/items/04153c6228516e48a55e

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】定数は、config gemで管理しましょう

環境

$ rails -v
Rails 6.0.3.1
$ ruby -v
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin19]

まずは、Gemのインストール

Gemfile
gem 'config'
$ bundle install --path vendor/bundle

configの初期設定を行う

configの初期設定のために関連ファイルをインストールする

$ bundle exec rails g config:install

定数定義と、使い方

config/settings.yml
service: 
  name: 'vdeep' 
  url: 'http://vdeep.net'

authentication_password: "foobarbaz"
$ rails c

> Settings.service.name
=> "vdeep"

> Settings.service[:name]
=> "vdeep"

> Settings[:service][:name]
=> "vdeep"

> Settings.authentication_password
=> "foobarbaz"

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

独学半年の実務未経験がRails+Nuxt.jsでSPA作ったので見て欲しい

はじめに

アプリを作ったのはいいものの、フィードバックをくれる人がいなかったのでQiitaで紹介記事を書くことにしました。
独学で周りにアドバイスをもらえる人がいないので、改善点などどんどん指摘してくださると幸いです。

自己紹介

作者は今年の2月末ごろから独学でプログラミングを学習しています。
高校時代にほんの少しHTMLを触ったことがある程度で、前提知識はほぼありませんでした。
現在はアプリを作成しながら、10月の基本情報に向けて勉強しています。
アプリ制作は今回で2作目になります。
前作URL
前作リポジトリ

アプリ概要

これが作成したアプリです。

サイトURL
githubリポジトリ

PolPa(ポルパ)は、学習時間に応じてレベルが上がる学習記録アプリです。
全体像がわかると思うので先にERを貼っておきます。

polpa.png

機能紹介

主な機能を一部紹介します。

ゲストログイン

localhost_8080_signup.png

新規登録フォームの下からゲストアカウントでログインできます。
気軽に試すことができるので、見学しに来てください。

学習時間を記録する

time-report.gif

このアプリのメイン機能です。記録された時間に応じて経験値を獲得できます。
タグをつけてジャンル分けできます。
記録した日には草が生えます。
記録にはコメントやいいねができます。

ユーザーをフォローする

follow.gif

ユーザーをフォローすると、タイムラインにそのユーザーの記録が表示されるようになります。
他のユーザーと交流して、モチベーションを高め合いましょう!

ユーザーを検索する

search.gif

ユーザー名で検索できます。
リアルタイムに検索結果が変化します。

レスポンシブデザイン

スマホやタブレット用のデザインにも対応しているので、モバイル端末から手軽に利用できます。

他にも紹介してない機能がたくさんあるので、ゲストログインで体験してください。

作った理由

サービスとして

他の学習管理アプリを使っていて、今まで積み上げてきたものをはかる指標があればもっとモチベーションを維持しやすくなるのではないかと考え、「学習時間を記録するとレベルが上がる」という要素をプラスした学習管理アプリがあれば面白いのではないか、と開発を始めました。

個人として

個人的には、Vue.jsとRailsを使って何かを作りたかったから開発を始めたという理由もあります。
作る前はこんな程度のアイデアで開発を始めてもいいのだろうかと思っていましたが、開発を進めていくうちに少し愛着が湧いてきました。

技術周りの話

使用している主な技術やリソース

  • Ruby、Rails ... バックエンド
  • Vue.js、Nuxt.js ... フロントエンド
  • Vuetify ... UIフレームワーク
  • PostgreSQL ... データベース
  • Docker、Docker-compose ... コンテナ仮想化
  • Git、GitHub ... バージョン管理システム
  • Firebase ... ログイン認証・ホスティング
  • heroku ... API用サーバー

Rails

バックエンドにはRailsを使用しています。Railsは学習期間が最も長く、前作でも使用しています。
新しく作るアプリは新しい要素を追加したいと思ったので、バックとフロントで別のフレームワークを使用する手法を取ることにしました。

Vue.js、Nuxt.js

Vue.jsは以前から興味があり、Railsとの共存も容易だったため、学習を開始しました。
Vue.jsは書籍一冊学習したのみだったので、雰囲気しか理解していない状態からコードを書き始めました。
Nuxt.js自体は開発を始めてから初めて触りました。

Vuetify

フロントのデザインはほとんどVuetifyに頼っています。アプリに必要なデザインコンポーネントが一通り揃っているので、一から作るにはめんどくさいデザインも簡単に実装できました。
種類が豊富なので、Vuetifyの公式リファレンスを見るだけで楽しいです。

Firebase

ログイン認証にはFirebaseを用いています。初めての開発手法だったため、自力で安全な認証・認可を実装できないと判断し、Firebaseを使用することにしました。

一人で開発して感じたこと

個人で開発をしていく上で良かったことと、つらかったこと・反省点をいくつかまとめてみます。

良かったこと

前作を制作した時も感じていましたが、アプリ制作は本当に勉強になります。
特に独学だとすべてのエラーを自力で解決しなければならないので、デバック力や問題解決能力が磨かれます。
一人で開発するとWeb開発の技術が全般的に必要になるので、次に勉強したい分野が明確になるのもいい点だと思います。

つらかったこと・反省点

  • エラーの解決方法がわからないとストレスがたまる
  • 開発するときのルールを決めておかないと徐々に雑になる
  • あれもこれもと機能を追加しているとキリがない

コードが予想に反する動きをして半日潰れたなんてことは日常茶飯事だったので慣れはしましたが、目に見える成果が全く得られないというのはストレスがたまります。ネットで検索してみてもそれらしい情報が全く出てこなかった時は本当に絶望します。
ただ、良かった点にも書いているようにメリットとは表裏一体です。
なんとか解決してきたので、自分の力にもなっているのかなと思います。

最初に張り切って、「今回はこの方法にしたがってコーティングして、この手法を使って開発を進めるぞ!」 なんて思っても大体続きませんでした。だんだん雑になってきて、めんどくさいからいいか、一人だからいいかとなりがちでした。次回があれば、ルールの徹底を意識したいと思います。

これからについて

もっと追加したい機能があるので時間があればアップデートしたいと思っています。
10月中旬の基本情報に向けてJavaを勉強するつもりなので、しばらくは時間がないと思いますが、アプリの完成度を高ていきたいのでフィードバックなどよろしくお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[備忘録] ``expect``の後ろは``()``か``{}``か。 (Module: RSpec::Matchers)

本編

RSpecのマッチャの書式についての個人的な小メモです。

RSpecのバージョン

RSpec 3.9
  - rspec-core 3.9.2
  - rspec-expectations 3.9.2
  - rspec-mocks 3.9.1
  - rspec-rails 4.0.0.beta3
  - rspec-support 3.9.3

expectの後ろは(){}か?

結論

下記のgem(rspec-expectations (3.9.2))のドキュメントでMathersのページに各マッチャの記法が列挙されています。用例も多く掲載されています。(#changeなど)

Module: RSpec::Matchers — Documentation for rspec-expectations (3.9.2)

expect(..).to be_xxx{..}を誤用した例

実際にシステムスペックのファイル内で(..){..}と書いてテストを実行した場合のエラーメッセージです。
be successfulのマッチャを使用するには、ブロックではなく引数を渡さなければならないということは教えてくれています。

エラーメッセージ例
You must pass an argument rather than a block to `expect` to use the provided matcher (be successful), or the matcher must implement `supports_block_expectations?`.

# ./spec/system/doing_edit_spec.rb:11:in `block (2 levels) in <top (required)>'
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

jQueryとancestryを使ったカテゴリー選択機能(Ajax)

はじめに

某プログラミングスクールにてフリマアプリを作成。
自分が実装した内容をメモとしてまとめたものです。

開発環境

  • Rails 5.2.3
  • Ruby 2,5.1
  • jQuery
  • gem ancestry

やりたいこと

某メルカリサイトにある「カテゴリーから探す」にマウスを当てると動的に切り替わるカテゴリー表示機能。
メルカリ
ただし、正直使い難いと個人的に思うので
プラスで以下の条件を付け加えました。

  • 親カテゴリー選択後、マウスを動かしただけでは子カテゴリーの表示は変えないこと。
  • カテゴリーの枠からマウスが外れてもすぐに消えないこと。
  • カテゴリーの表示枠は必要な分の高さだけにすること。


最初に完成形を見たい方はこちらをクイック
Image from Gyazo


正直余計な条件かもしれないがけど、実際触って個人的に気になったので導入します。

事前準備

  • gem ancestryを導入。(導入方法はたくさん記事があるので省略します)
  • 次にjQuerygem jquery-railsを導入、設定する。

ここでポイントとなるのが、turbolinksの無効化。
turbolinksは処理の高速化などのメリットもありますが特定の操作で不安定になることがあります。
今回はturbolinksを無効化することでブラウザバック後でも安定した挙動になります。

app/assets/javascripts/applications.js
// //= require turbolinksの行を削除

//= require jquery
//= require jquery_ujs
//= require_tree .

app/veiws/layouts/application.html.haml
    = stylesheet_link_tag    'application', media: 'all'
    = javascript_include_tag 'application'
-# 'data-turbolinks-track': 'reload' stylesheet_link_tagのこの項目を削除
-# 'data-turbolinks-track': 'reload' javascript_include_tagのこの項目を削除


ちなみにカテゴリーを登録するseeds.rbはこちらです
db/seeds.rb
categories=[
  {level1:"レディース",level1_children:[
                                      {level2:"トップス",
                                                          level2_children:["Tシャツ/カットソー(半袖/袖なし)","Tシャツ/カットソー(七分/長袖)","シャツ/ブラウス(半袖/袖なし)","シャツ/ブラウス(七分/長袖)","ポロシャツ","キャミソール","タンクトップ","ホルターネック","ニット/セーター","チュニック","カーディガン/ボレロ","アンサンブル","ベスト/ジレ","パーカー","トレーナー/スウェット","ベアトップ/チューブトップ","ジャージ","その他"]
                                      },
                                      {level2:"ジャケット/アウター",
                                                          level2_children:["テーラードジャケット","ノーカラージャケット", "Gジャン/デニムジャケット","レザージャケット","ダウンジャケット","ライダースジャケット","ミリタリージャケット","ダウンベスト","ジャンパー/ブルゾン","ポンチョ","ロングコート","トレンチコート","ダッフルコート","ピーコート","チェスターコート","モッズコート","スタジャン","毛皮/ファーコート","スプリングコート","スカジャン","その他"]
                                      },
                                      {level2:"パンツ",
                                                          level2_children:["デニム/ジーンズ","ショートパンツ","カジュアルパンツ","ハーフパンツ","チノパン","ワークパンツ/カーゴパンツ","クロップドパンツ","サロペット/オーバーオール","オールインワン","サルエルパンツ","ガウチョパンツ","その他"]
                                      },
                                      {level2:"スカート",
                                                          level2_children:["ミニスカート","ひざ丈スカート","ロングスカート","キュロット","その他"]
                                      },
                                      {level2:"ワンピース",
                                                          level2_children:["ミニワンピース","ひざ丈ワンピース","ロングワンピース","その他"]
                                      },
                                      {level2:"靴",
                                                          level2_children:["ハイヒール/パンプス","ブーツ","サンダル","スニーカー","ミュール","モカシン","ローファー/革靴","フラットシューズ/バレエシューズ","長靴/レインシューズ","その他"]
                                      },
                                      {level2:"ルームウェア/パジャマ",
                                                          level2_children:["パジャマ","ルームウェア","その他"]
                                      },
                                      {level2:"レッグウェア",
                                                          level2_children:["ソックス","スパッツ/レギンス","ストッキング/タイツ","レッグウォーマー","その他"]
                                      },
                                      {level2:"帽子",
                                                          level2_children:["ニットキャップ/ビーニー","ハット","ハンチング/ベレー帽","キャップ","キャスケット","麦わら帽子","その他"]
                                      },
                                      {level2:"バッグ",
                                                          level2_children:["ハンドバッグ","トートバッグ","エコバッグ","リュック/バックパック","ボストンバッグ","スポーツバッグ","ショルダーバッグ","クラッチバッグ","ポーチ/バニティ","ボディバッグ/ウェストバッグ","マザーズバッグ","メッセンジャーバッグ","ビジネスバッグ","旅行用バッグ/キャリーバッグ","ショップ袋","和装用バッグ","かごバッグ","その他"]
                                      },
                                      {level2:"アクセサリー",
                                                          level2_children:["ネックレス","ブレスレット","バングル/リストバンド","リング","ピアス(片耳用)","ピアス(両耳用)","イヤリング","アンクレット","ブローチ/コサージュ","チャーム","その他"]
                                      },
                                      {level2:"ヘアアクセサリー",
                                                          level2_children:["ヘアゴム/シュシュ","ヘアバンド/カチューシャ","ヘアピン","その他"]
                                      },
                                      {level2:"小物",
                                                          level2_children:["長財布","折り財布","コインケース/小銭入れ","名刺入れ/定期入れ","キーケース","キーホルダー","手袋/アームカバー","ハンカチ","ベルト","マフラー/ショール","ストール/スヌード","バンダナ/スカーフ","ネックウォーマー","サスペンダー","サングラス/メガネ","モバイルケース/カバー","手帳","イヤマフラー","傘","レインコート/ポンチョ","ミラー","タバコグッズ","その他"]
                                      },
                                      {level2:"時計",
                                                          level2_children:["腕時計(アナログ)","腕時計(デジタル)","ラバーベルト","レザーベルト","金属ベルト","その他"]
                                      },
                                      {level2:"ウィッグ/エクステ",
                                                          level2_children:["前髪ウィッグ","ロングストレート","ロングカール","ショートストレート","ショートカール","その他"]
                                      },
                                      {level2:"浴衣/水着",
                                                          level2_children:["浴衣","着物","振袖","長襦袢/半襦袢","水着セパレート","水着ワンピース","水着スポーツ用","その他"]
                                      },
                                      {level2:"スーツ/フォーマル/ドレス",
                                                          level2_children:["スカートスーツ上下","パンツスーツ上下","ドレス","パーティーバッグ","シューズ","ウェディング","その他"]
                                      },
                                      {level2:"マタニティ",
                                                          level2_children:["トップス","アウター","インナー","ワンピース","パンツ/スパッツ","スカート","パジャマ","授乳服","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["コスプレ","下着","その他"]
                                      }
                                      ]
  },
  {level1:"メンズ",level1_children:[
                                      {level2:"トップス",
                                                          level2_children:["Tシャツ/カットソー(半袖/袖なし)","Tシャツ/カットソー(七分/長袖)","シャツ","ポロシャツ","タンクトップ","ニット/セーター","パーカー","カーディガン","スウェット","ジャージ","ベスト","その他"]
                                      },
                                      {level2:"ジャケット/アウター",
                                                          level2_children:["テーラードジャケット","ノーカラージャケット","Gジャン/デニムジャケット","レザージャケット","ダウンジャケット","ライダースジャケット","ミリタリージャケット","ナイロンジャケット","フライトジャケット","ダッフルコート","ピーコート","ステンカラーコート","トレンチコート","モッズコート","チェスターコート","スタジャン","スカジャン","ブルゾン","マウンテンパーカー","ダウンベスト","ポンチョ","カバーオール","その他"]
                                      },
                                      {level2:"パンツ",
                                                          level2_children:["デニム/ジーンズ","ワークパンツ/カーゴパンツ","スラックス","チノパン","ショートパンツ","ペインターパンツ","サルエルパンツ","オーバーオール","その他"]
                                      },
                                      {level2:"靴",
                                                          level2_children:["スニーカー","サンダル","ブーツ","モカシン","ドレス/ビジネス","長靴/レインシューズ","デッキシューズ","その他"]
                                      },
                                      {level2:"バッグ",
                                                          level2_children:["ショルダーバッグ","トートバッグ","ボストンバッグ","リュック/バックパック","ウエストポーチ","ボディーバッグ","ドラムバッグ","ビジネスバッグ","トラベルバッグ","メッセンジャーバッグ","エコバッグ","その他"]
                                      },
                                      {level2:"スーツ",
                                                          level2_children:["スーツジャケット","スーツベスト","スラックス","セットアップ","その他"]
                                      },
                                      {level2:"帽子",
                                                          level2_children:["キャップ","ハット","ニットキャップ/ビーニー","ハンチング/ベレー帽","キャスケット","サンバイザー","その他"]
                                      },
                                      {level2:"アクセサリー",
                                                          level2_children:["ネックレス","ブレスレット","バングル/リストバンド","リング","ピアス(片耳用)","ピアス(両耳用)","アンクレット","その他"]
                                      },
                                      {level2:"小物",
                                                          level2_children:["長財布","折り財布","マネークリップ","コインケース/小銭入れ","名刺入れ/定期入れ","キーケース","キーホルダー","ネクタイ","手袋","ハンカチ","ベルト","マフラー","ストール","バンダナ","ネックウォーマー","サスペンダー","ウォレットチェーン","サングラス/メガネ","モバイルケース/カバー","手帳","ストラップ","ネクタイピン","カフリンクス","イヤマフラー","傘","レインコート","ミラー","タバコグッズ","その他"]
                                      },
                                      {level2:"時計",
                                                          level2_children:["腕時計(アナログ)","腕時計(デジタル)","ラバーベルト","レザーベルト","金属ベルト","その他"]
                                      },
                                      {level2:"水着",
                                                          level2_children:["一般水着","スポーツ用","アクセサリー","その他"]
                                      },
                                      {level2:"レッグウェア",
                                                          level2_children:["ソックス","レギンス/スパッツ","レッグウォーマー","その他"]
                                      },
                                      {level2:"アンダーウェア",
                                                          level2_children:["トランクス","ボクサーパンツ","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  },
  {level1:"ベビー・キッズ",level1_children:[
                                      {level2:"ベビー服(女の子用) ~95cm",
                                                          level2_children:["トップス","アウター","パンツ","スカート","ワンピース","ベビードレス","おくるみ","下着/肌着","パジャマ","ロンパース","その他"]
                                      },
                                      {level2:"ベビー服(男の子用) ~95cm",
                                                          level2_children:["トップス","アウター","パンツ","おくるみ","下着/肌着","パジャマ","ロンパース","その他"]
                                      },
                                      {level2:"ベビー服(男女兼用) ~95cm",
                                                          level2_children:["トップス","アウター","パンツ","おくるみ","下着/肌着","パジャマ","ロンパース","その他"]
                                      },
                                      {level2:"キッズ服(女の子用) 100cm~",
                                                          level2_children:["コート","ジャケット/上着","トップス(Tシャツ/カットソー)","トップス(トレーナー)","トップス(チュニック)","トップス(タンクトップ)","トップス(その他)","スカート","パンツ","ワンピース","セットアップ","パジャマ","フォーマル/ドレス","和服","浴衣","甚平","水着","その他"]
                                      },
                                      {level2:"キッズ服(男の子用) 100cm~",
                                                          level2_children:["コート","ジャケット/上着","トップス(Tシャツ/カットソー)","トップス(トレーナー)","トップス(その他)","パンツ","セットアップ","パジャマ","フォーマル/ドレス","和服","浴衣","甚平","水着","その他"]
                                      },
                                      {level2:"キッズ服(男女兼用) 100cm~",
                                                          level2_children:["コート","ジャケット/上着","トップス(Tシャツ/カットソー)","トップス(トレーナー)","トップス(その他)","ボトムス","パジャマ","その他"]
                                      },
                                      {level2:"キッズ靴",
                                                          level2_children:["スニーカー","サンダル","ブーツ","長靴","その他"]
                                      },
                                      {level2:"子ども用ファッション小物",
                                                          level2_children:["靴下/スパッツ","帽子","エプロン","サスペンダー","タイツ","ハンカチ","バンダナ","ベルト","マフラー","傘","手袋","スタイ","バッグ","その他"]
                                      },
                                      {level2:"おむつ/トイレ/バス",
                                                          level2_children:["おむつ用品","おまる/補助便座","トレーニングパンツ","ベビーバス","お風呂用品","その他"]
                                      },
                                      {level2:"外出/移動用品",
                                                          level2_children:["ベビーカー","抱っこひも/スリング","チャイルドシート","その他"]
                                      },
                                      {level2:"授乳/食事",
                                                          level2_children:["ミルク","ベビーフード","ベビー用食器","その他"]
                                      },
                                      {level2:"ベビー家具/寝具/室内用品",
                                                          level2_children:["ベッド","布団/毛布","イス","たんす","その他"]
                                      },
                                      {level2:"おもちゃ",
                                                          level2_children:["おふろのおもちゃ","がらがら","オルゴール","ベビージム","手押し車/カタカタ","知育玩具","その他"]
                                      },
                                      {level2:"行事/記念品",
                                                          level2_children:["お宮参り用品","お食い初め用品","アルバム","手形/足形","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["母子手帳用品","その他"]
                                      }
                                      ]
  },
  {level1:"インテリア・住まい・小物",level1_children:[
                                      {level2:"キッチン/食器",
                                                          level2_children:["食器","調理器具","収納/キッチン雑貨","弁当用品","カトラリー(スプーン等)","テーブル用品","容器","エプロン","アルコールグッズ","浄水機","その他"]
                                      },
                                      {level2:"ベッド/マットレス",
                                                          level2_children:["セミシングルベッド","シングルベッド","セミダブルベッド","ダブルベッド","ワイドダブルベッド","クイーンベッド","キングベッド","脚付きマットレスベッド","マットレス","すのこベッド","ロフトベッド/システムベッド","簡易ベッド/折りたたみベッド","収納付き","その他"]
                                      },
                                      {level2:"ソファ/ソファベッド",
                                                          level2_children:["ソファセット","シングルソファ","ラブソファ","トリプルソファ","オットマン","コーナーソファ","ビーズソファ/クッションソファ","ローソファ/フロアソファ","ソファベッド","応接セット","ソファカバー","リクライニングソファ","その他"]
                                      },
                                      {level2:"椅子/チェア",
                                                          level2_children:["一般","スツール","ダイニングチェア","ハイバックチェア","ロッキングチェア","座椅子","折り畳みイス","デスクチェア","その他"]
                                      },
                                      {level2:"机/テーブル",
                                                          level2_children:["こたつ","カウンターテーブル","サイドテーブル","センターテーブル","ダイニングテーブル","座卓/ちゃぶ台","アウトドア用","パソコン用","事務机/学習机","その他"]
                                      },
                                      {level2:"収納家具",
                                                          level2_children:["リビング収納","キッチン収納","玄関/屋外収納","バス/トイレ収納","本収納","本/CD/DVD収納","洋服タンス/押入れ収納","電話台/ファックス台","ドレッサー/鏡台","棚/ラック","ケース/ボックス","その他"]
                                      },
                                      {level2:"ラグ/カーペット/マット",
                                                          level2_children:["ラグ","カーペット","ホットカーペット","玄関/キッチンマット","トイレ/バスマット","その他"]
                                      },
                                      {level2:"カーテン/ブラインド",
                                                          level2_children:["カーテン","ブラインド","ロールスクリーン","のれん","その他"]
                                      },
                                      {level2:"ライト/照明",
                                                          level2_children:["蛍光灯/電球","天井照明","フロアスタンド","その他"]
                                      },
                                      {level2:"寝具",
                                                          level2_children:["布団/毛布","枕","シーツ/カバー","その他"]
                                      },
                                      {level2:"インテリア小物",
                                                          level2_children:["ごみ箱","ウェルカムボード","オルゴール","クッション","クッションカバー","スリッパラック","ティッシュボックス","バスケット/かご","フォトフレーム","マガジンラック","モビール","花瓶","灰皿","傘立て","小物入れ","置時計","掛時計/柱時計","鏡(立て掛け式)","鏡(壁掛け式)","置物","風鈴","植物/観葉植物","その他"]
                                      },
                                      {level2:"季節/年中行事",
                                                          level2_children:["正月","成人式","バレンタインデー","ひな祭り","子どもの日","母の日","父の日","サマーギフト/お中元","夏/夏休み","ハロウィン","敬老の日","七五三","お歳暮","クリスマス","冬一般","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  },
  {level1:"本・音楽・ゲーム",level1_children:[
                                      {level2:"本",
                                                          level2_children:["文学/小説","人文/社会","ノンフィクション/教養","地図/旅行ガイド","ビジネス/経済","健康/医学","コンピュータ/IT","趣味/スポーツ/実用","住まい/暮らし/子育て","アート/エンタメ","洋書","絵本","参考書","その他"]
                                      },
                                      {level2:"漫画",
                                                          level2_children:["全巻セット","少年漫画","少女漫画","青年漫画","女性漫画","同人誌","その他"]
                                      },
                                      {level2:"雑誌",
                                                          level2_children:["アート/エンタメ/ホビー","ファッション","ニュース/総合","趣味/スポーツ","その他"]
                                      },
                                      {level2:"CD",
                                                          level2_children:["邦楽","洋楽","アニメ","クラシック","K-POP/アジア","キッズ/ファミリー","その他"]
                                      },
                                      {level2:"DVD/ブルーレイ",
                                                          level2_children:["外国映画","日本映画","アニメ","TVドラマ","ミュージック","お笑い/バラエティ","スポーツ/フィットネス","キッズ/ファミリー","その他"]
                                      },
                                      {level2:"レコード",
                                                          level2_children:["邦楽","洋楽","その他"]
                                      },
                                      {level2:"テレビゲーム",
                                                          level2_children:["家庭用ゲーム本体","家庭用ゲームソフト","携帯用ゲーム本体","携帯用ゲームソフト","PCゲーム","その他"]
                                      }
                                      ]
  },
  {level1:"おもちゃ・ホビー・グッズ",level1_children:[
                                      {level2:"おもちゃ",
                                                          level2_children:["キャラクターグッズ","ぬいぐるみ","小物/アクセサリー","模型/プラモデル","ミニカー","トイラジコン","プラモデル","ホビーラジコン","鉄道模型","その他"]
                                      },
                                      {level2:"タレントグッズ",
                                                          level2_children:["アイドル","ミュージシャン","タレント/お笑い芸人","スポーツ選手","その他"]
                                      },
                                      {level2:"コミック/アニメグッズ",
                                                          level2_children:["ストラップ","キーホルダー","バッジ","カード","クリアファイル","ポスター","タオル","その他"]
                                      },
                                      {level2:"トレーディングカード",
                                                          level2_children:["遊戯王","マジック:ザ・ギャザリング","ポケモンカードゲーム","デュエルマスターズ","バトルスピリッツ","プリパラ","アイカツ","カードファイト!! ヴァンガード","ヴァイスシュヴァルツ","プロ野球オーナーズリーグ","ベースボールヒーローズ","ドラゴンボール","スリーブ","その他"]
                                      },
                                      {level2:"フィギュア",
                                                          level2_children:["コミック/アニメ","特撮","ゲームキャラクター","SF/ファンタジー/ホラー","アメコミ","スポーツ","ミリタリー","その他"]
                                      },
                                      {level2:"楽器/器材",
                                                          level2_children:["エレキギター","アコースティックギター","ベース","エフェクター","アンプ","弦楽器","管楽器","鍵盤楽器","打楽器","和楽器","楽譜/スコア","レコーディング/PA機器","DJ機器","DTM/DAW","その他"]
                                      },
                                      {level2:"コレクション",
                                                          level2_children:["武具","使用済切手/官製はがき","旧貨幣/金貨/銀貨/記念硬貨","印刷物","ノベルティグッズ","その他"]
                                      },
                                      {level2:"ミリタリー",
                                                          level2_children:["トイガン","個人装備","その他"]
                                      },
                                      {level2:"美術品",
                                                          level2_children:["陶芸","ガラス","漆芸","金属工芸","絵画/タペストリ","版画","彫刻/オブジェクト","書","写真","その他"]
                                      },
                                      {level2:"アート用品",
                                                          level2_children:["画材","額縁","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["トランプ/UNO","カルタ/百人一首","ダーツ","ビリヤード","麻雀","パズル/ジグソーパズル","囲碁/将棋","オセロ/チェス","人生ゲーム","野球/サッカーゲーム","スポーツ","三輪車/乗り物","ヨーヨー","模型製作用品","鉄道","航空機","アマチュア無線","パチンコ/パチスロ","その他"]
                                      }
                                      ]
  },
  {level1:"コスメ・香水・美容",level1_children:[
                                      {level2:"ベースメイク",
                                                          level2_children:["ファンデーション","化粧下地","コントロールカラー","BBクリーム","CCクリーム","コンシーラー","フェイスパウダー","トライアルセット/サンプル","その他"]
                                      },
                                      {level2:"メイクアップ",
                                                          level2_children:["アイシャドウ","口紅","リップグロス","リップライナー","チーク","フェイスカラー","マスカラ","アイライナー","つけまつげ","アイブロウペンシル","パウダーアイブロウ","眉マスカラ","トライアルセット/サンプル","メイク道具/化粧小物","美顔用品/美顔ローラー","その他"]
                                      },
                                      {level2:"ネイルケア",
                                                          level2_children:["ネイルカラー","カラージェル","ネイルベースコート/トップコート","ネイルアート用品","ネイルパーツ","ネイルチップ/付け爪","手入れ用具","除光液","その他"]
                                      },
                                      {level2:"香水",
                                                          level2_children:["香水(女性用)","香水(男性用)","ユニセックス","ボディミスト","その他"]
                                      },
                                      {level2:"スキンケア/基礎化粧品",
                                                          level2_children:["化粧水/ローション","乳液/ミルク","美容液","フェイスクリーム","洗顔料","クレンジング/メイク落とし","パック/フェイスマスク","ジェル/ゲル","ブースター/導入液","アイケア","リップケア","トライアルセット/サンプル","洗顔グッズ","その他"]
                                      },
                                      {level2:"ヘアケア",
                                                          level2_children:["シャンプー","トリートメント","コンディショナー","リンス","スタイリング剤","カラーリング剤","ブラシ","その他"]
                                      },
                                      {level2:"ボディケア",
                                                          level2_children:["オイル/クリーム","ハンドクリーム","ローション","日焼け止め/サンオイル","ボディソープ","入浴剤","制汗/デオドラント","フットケア","その他"]
                                      },
                                      {level2:"オーラルケア",
                                                          level2_children:["口臭防止/エチケット用品","歯ブラシ","その他"]
                                      },
                                      {level2:"リラクゼーション",
                                                          level2_children:["エッセンシャルオイル","お香/香炉","キャンドル","リラクゼーショングッズ","その他"]
                                      },
                                      {level2:"ダイエット",
                                                          level2_children:["ダイエット食品","エクササイズ用品","体重計","体脂肪計","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["健康用品","看護/介護用品","救急/衛生用品","その他"]
                                      }
                                      ]
  },
  {level1:"家電・スマホ・カメラ",level1_children:[
                                      {level2:"スマートフォン/携帯電話",
                                                          level2_children:["スマートフォン本体","バッテリー/充電器","携帯電話本体","PHS本体","その他"]
                                      },
                                      {level2:"スマホアクセサリー",
                                                          level2_children:["Android用ケース","iPhone用ケース","カバー","イヤホンジャック","ストラップ","フィルム","自撮り棒","その他"]
                                      },
                                      {level2:"PC/タブレット",
                                                          level2_children:["タブレット","ノートPC","デスクトップ型PC","ディスプレイ","電子ブックリーダー","PC周辺機器","PCパーツ","その他"]
                                      },
                                      {level2:"カメラ",
                                                          level2_children:["デジタルカメラ","ビデオカメラ","レンズ(単焦点)","レンズ(ズーム)","フィルムカメラ","防犯カメラ","その他"]
                                      },
                                      {level2:"テレビ/映像機器",
                                                          level2_children:["テレビ","プロジェクター","ブルーレイレコーダー","DVDレコーダー","ブルーレイプレーヤー","DVDプレーヤー","映像用ケーブル","その他"]
                                      },
                                      {level2:"オーディオ機器",
                                                          level2_children:["ポータブルプレーヤー","イヤフォン","ヘッドフォン","アンプ","スピーカー","ケーブル/シールド","ラジオ","その他"]
                                      },
                                      {level2:"美容/健康",
                                                          level2_children:["ヘアドライヤー","ヘアアイロン","美容機器","電気シェーバー","電動歯ブラシ","その他"]
                                      },
                                      {level2:"冷暖房/空調",
                                                          level2_children:["エアコン","空気清浄器","加湿器","扇風機","除湿機","ファンヒーター","電気ヒーター","オイルヒーター","ストーブ","ホットカーペット","こたつ","電気毛布","その他"]
                                      },
                                      {level2:"生活家電",
                                                          level2_children:["冷蔵庫","洗濯機","炊飯器","電子レンジ/オーブン","調理機器","アイロン","掃除機","エスプレッソマシン","コーヒーメーカー","衣類乾燥機","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  },
  {level1:"スポーツ・レジャー",level1_children:[
                                      {level2:"ゴルフ",
                                                          level2_children:["クラブ","ウエア(男性用)","ウエア(女性用)","バッグ","シューズ(男性用)","シューズ(女性用)","アクセサリー","その他"]
                                      },
                                      {level2:"フィッシング",
                                                          level2_children:["ロッド","リール","ルアー用品","ウエア","釣り糸/ライン","その他"]
                                      },
                                      {level2:"自転車",
                                                          level2_children:["自転車本体","ウエア","パーツ","アクセサリー","バッグ","工具/メンテナンス","その他"]
                                      },
                                      {level2:"トレーニング/エクササイズ",
                                                          level2_children:["ランニング","ウォーキング","ヨガ","トレーニング用品","その他"]
                                      },
                                      {level2:"野球",
                                                          level2_children:["ウェア","シューズ","グローブ","バット","アクセサリー","防具","練習機器","記念グッズ","応援グッズ","その他"]
                                      },
                                      {level2:"サッカー/フットサル",
                                                          level2_children:["ウェア","シューズ","ボール","アクセサリー","記念グッズ","応援グッズ","その他"]
                                      },
                                      {level2:"テニス",
                                                          level2_children:["ラケット(硬式用)","ラケット(軟式用)","ウェア","シューズ","ボール","アクセサリー","記念グッズ","応援グッズ","その他"]
                                      },
                                      {level2:"スノーボード",
                                                          level2_children:["ボード","バインディング","ブーツ(男性用)","ブーツ(女性用)","ブーツ(子ども用)","ウエア/装備(男性用)","ウエア/装備(女性用)","ウエア/装備(子ども用)","アクセサリー","バッグ","その他"]
                                      },
                                      {level2:"スキー",
                                                          level2_children:["板","ブーツ(男性用)","ブーツ(女性用)","ブーツ(子ども用)","ビンディング","ウエア(男性用)","ウエア(女性用)","ウエア(子ども用)","ストック","その他"]
                                      },
                                      {level2:"その他スポーツ",
                                                          level2_children:["ダンス/バレエ","サーフィン","バスケットボール","バドミントン","バレーボール","スケートボード","陸上競技","ラグビー","アメリカンフットボール","ボクシング","ボウリング","その他"]
                                      },
                                      {level2:"アウトドア",
                                                          level2_children:["テント/タープ","ライト/ランタン","寝袋/寝具","テーブル/チェア","ストーブ/コンロ","調理器具","食器","登山用品","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["旅行用品","その他"]
                                      }
                                      ]
  },
  {level1:"ハンドメイド",level1_children:[
                                      {level2:"アクセサリー(女性用)",
                                                          level2_children:["ピアス","イヤリング","ネックレス","ブレスレット","リング","チャーム","ヘアゴム","アンクレット","その他"]
                                      },
                                      {level2:"ファッション/小物",
                                                          level2_children:["バッグ(女性用)","バッグ(男性用)","財布(女性用)","財布(男性用)","ファッション雑貨","その他"]
                                      },
                                      {level2:"アクセサリー/時計",
                                                          level2_children:["アクセサリー(男性用)","時計(女性用)","時計(男性用)","その他"]
                                      },
                                      {level2:"日用品/インテリア",
                                                          level2_children:["キッチン用品","家具","文房具","アート/写真","アロマ/キャンドル","フラワー/ガーデン","その他"]
                                      },
                                      {level2:"趣味/おもちゃ",
                                                          level2_children:["クラフト/布製品","おもちゃ/人形","その他"]
                                      },
                                      {level2:"キッズ/ベビー",
                                                          level2_children:["ファッション雑貨","スタイ/よだれかけ","外出用品","ネームタグ","その他"]
                                      },
                                      {level2:"素材/材料",
                                                          level2_children:["各種パーツ","生地/糸","型紙/パターン","その他"]
                                      },
                                      {level2:"二次創作物",
                                                          level2_children:["Ingress","クリエイターズ宇宙兄弟","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  },
  {level1:"チケット",level1_children:[
                                      {level2:"音楽",
                                                          level2_children:["男性アイドル","女性アイドル","韓流","国内アーティスト","海外アーティスト","音楽フェス","声優/アニメ","その他"]
                                      },
                                      {level2:"スポーツ",
                                                          level2_children:["サッカー","野球","テニス","格闘技/プロレス","相撲/武道","ゴルフ","バレーボール","バスケットボール","モータースポーツ","ウィンタースポーツ","その他"]
                                      },
                                      {level2:"演劇/芸能",
                                                          level2_children:["ミュージカル","演劇","伝統芸能","落語","お笑い","オペラ","サーカス","バレエ","その他"]
                                      },
                                      {level2:"イベント",
                                                          level2_children:["声優/アニメ","キッズ/ファミリー","トークショー/講演会","その他"]
                                      },
                                      {level2:"映画",
                                                          level2_children:["邦画","洋画","その他"]
                                      },
                                      {level2:"施設利用券",
                                                          level2_children:["遊園地/テーマパーク","美術館/博物館","スキー場","ゴルフ場","フィットネスクラブ","プール","ボウリング場","水族館","動物園","その他"]
                                      },
                                      {level2:"優待券/割引券",
                                                          level2_children:["ショッピング","レストラン/食事券","フード/ドリンク券","宿泊券","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  },
  {level1:"自動車・オートバイ",level1_children:[
                                      {level2:"自動車本体",
                                                          level2_children:["国内自動車本体","外国自動車本体"]
                                      },
                                      {level2:"自動車タイヤ/ホイール",
                                                          level2_children:["タイヤ/ホイールセット","タイヤ","ホイール","その他"]
                                      },
                                      {level2:"自動車パーツ",
                                                          level2_children:["サスペンション","ブレーキ","外装、エアロパーツ","ライト","内装品、シート","ステアリング","マフラー・排気系","エンジン、過給器、冷却装置","クラッチ、ミッション、駆動系","電装品","補強パーツ","汎用パーツ","外国自動車用パーツ","その他"]
                                      },
                                      {level2:"自動車アクセサリー",
                                                          level2_children:["車内アクセサリー","カーナビ","カーオーディオ","車外アクセサリー","メンテナンス用品","チャイルドシート","ドライブレコーダー","レーダー探知機","カタログ/マニュアル","セキュリティ","ETC","その他"]
                                      },
                                      {level2:"オートバイ車体",
                                                          level2_children:["国内産","外国産"]
                                      },
                                      {level2:"オートバイパーツ",
                                                          level2_children:["タイヤ","マフラー","エンジン、冷却装置","カウル、フェンダー、外装","サスペンション","ホイール","シート","ブレーキ","タンク","ライト、ウィンカー","チェーン、スプロケット、駆動系","メーター","電装系","ミラー","外国オートバイ用パーツ","その他"]
                                      },
                                      {level2:"オートバイアクセサリー",
                                                          level2_children:["ヘルメット/シールド","バイクウエア/装備","アクセサリー","メンテナンス","カタログ/マニュアル","その他"]
                                      }
                                      ]
  },
  {level1:"その他",level1_children:[
                                      {level2:"まとめ売り",
                                                          level2_children:["セット品","その他"]
                                      },
                                      {level2:"ペット用品",
                                                          level2_children:["ペットフード","犬用品","猫用品","魚用品/水草","小動物用品","爬虫類/両生類用品","かご/おり","鳥用品","虫類用品","その他"]
                                      },
                                      {level2:"食品",
                                                          level2_children:["菓子","米","野菜","果物","調味料","魚介類(加工食品)","肉類(加工食品)","その他 加工食品","その他"]
                                      },
                                      {level2:"飲料/酒",
                                                          level2_children:["コーヒー","ソフトドリンク","ミネラルウォーター","茶","ウイスキー","ワイン","ブランデー","焼酎","日本酒","ビール、発泡酒","その他"]
                                      },
                                      {level2:"日用品/生活雑貨/旅行",
                                                          level2_children:["タオル/バス用品","日用品/生活雑貨","洗剤/柔軟剤","旅行用品","防災関連グッズ","その他"]
                                      },
                                      {level2:"アンティーク/コレクション",
                                                          level2_children:["雑貨","工芸品","家具","印刷物","その他"]
                                      },
                                      {level2:"文房具/事務用品",
                                                          level2_children:["筆記具","ノート/メモ帳","テープ/マスキングテープ","カレンダー/スケジュール","アルバム/スクラップ","ファイル/バインダー","はさみ/カッター","カードホルダー/名刺管理","のり/ホッチキス","その他"]
                                      },
                                      {level2:"事務/店舗用品",
                                                          level2_children:["オフィス用品一般","オフィス家具","店舗用品","OA機器","ラッピング/包装","その他"]
                                      },
                                      {level2:"その他",
                                                          level2_children:["その他"]
                                      }
                                      ]
  }
]
categories.each.with_index(1) do |category,i|
  level1_var="@category#{i}"
  level1_val= Category.create(name:"#{category[:level1]}")
  eval("#{level1_var} = level1_val")
    category[:level1_children].each.with_index(1) do |level1_child,j|
      level2_var="#{level1_var}_#{j}"
      level2_val= eval("#{level1_var}.children.create(name:level1_child[:level2])")
      eval("#{level2_var} = level2_val")
        level1_child[:level2_children].each do |level2_children_val|
          eval("#{level2_var}.children.create(name:level2_children_val)")
        end
    end
end

この状態で
rake db:seedすればancestryの形式でDBにカテゴリーを登録できます。


  • ajaxを行うためのルーティングとコントローラーの設定を行います。 (今回はcategories_controller.rbに記載します)
config/routes.rb
Rails.application.routes.draw do
  resources :categories do
    collection do
      get :search
    end
  end
end
app/controllers/categories_controller.rb
class CategoriesController < ApplicationController
  before_action :set_parents

  def search
    respond_to do |format|
      format.html
      format.json
  end

  private

  def set_parents
    @parents = Category.where(ancestry: nil)
  end
end
  • 今回の機能を表示させるところには上記のbefore_actionを各コントローラーに追加してください。

コードを書く

まずは表示させる場所を作ります。(今回は部分テンプレートのヘッダーのビューに記載してます。)

app/veiws/shared/_header.html.haml
%ul.header__headerInner__nav__listsLeft
  %li.header__headerInner__nav__listsLeft__item
    = link_to categories_path, id: 'categoBtn' do
      カテゴリーから探す
    #tree_menu
      %ul.categoryTree
        - @parents.each do |parent|
          %li.category_parent
            = link_to category_path(parent) do
              %input{type: "button", value: "#{parent.name}", name: "#{parent.id}", class: "parent_btn"}
        %li.category_parent
          = link_to categories_path do
            %input{type: "button", value: "全てのカテゴリー", id: "all_btn"}
      %ul.categoryTree-child
      %ul.categoryTree-grandchild


(参考)今回使用したスタイルシートはこちらです
#tree_menu {
  width: 200px;
  position: absolute;
  z-index: 10;
  display: flex;

  .categoryTree {
    display: none;
    height: 100%;
    border: 1px solid #34AEB3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    a {
      padding: 0;

      input[type="button"] {
        min-width: 200px;
        height: 35px;
        font-size: 14px;
        text-decoration: none;
        color: #3ccace;
        background: #ffffff;
        outline: none;
        transition: .4s;
        border: #ffffff;
        padding: 0 0.9em;
        cursor: pointer;
      }

      input[type="button"]:hover {
        background: #62d4d7;
        color: white;
      }

      input[type="button"]:active {
        -webkit-transform: translateY(2px);
        transform: translateY(2px);
        box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
        background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
      }
    }
  }

  .categoryTree-child {
    display: none;
    height: 100%;
    border: 1px solid #34AEB3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    .category_child {

      a {
        padding: 0;

        input[type="button"] {
          min-width: 230px;
          height: 35px;
          font-size: 14px;
          text-decoration: none;
          color: #3ccace;
          background: #ffffff;
          outline: none;
          transition: .4s;
          border: #ffffff;
          padding: 0 0.9em;
          cursor: pointer;
        }

        input[type="button"]:hover {
          background: #62d4d7;
          color: white;
        }

        input[type="button"]:active {
          -webkit-transform: translateY(2px);
          transform: translateY(2px);
          box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
          background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
        }
      }
    }
  }

  .categoryTree-grandchild {
    display: none;
    height: 100%;
    border: 1px solid #34AEB3;
    background-color: #ffffff;
    border-radius: 4px;
    box-shadow: 4px 4px 4px #999;

    a {
      padding: 0;

      input[type="button"] {
        min-width: 250px;
        height: 35px;
        font-size: 14px;
        text-decoration: none;
        color: #3ccace;
        background: #ffffff;
        outline: none;
        transition: .4s;
        border: #ffffff;
        padding: 0 0.9em;
        cursor: pointer;
      }

      input[type="button"]:hover {
        border: 1px solid #62d4d7;
        background: #62d4d7;
        color: white;
      }

      input[type="button"]:active {
        -webkit-transform: translateY(2px);
        transform: translateY(2px);
        box-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
        background-image: linear-gradient(#b1e9eb 0%, #30a1a4 100%);
      }
    }
  }
}


次にjQueryを書いていきます。
まずはマウスが「カテゴリーを探す」ボタンに来たときに最初のイベントを発火させます。

app/assets/javascripts/categories.js
$(document).ready(function () {
  // 親カテゴリーを表示
  $('#categoBtn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    $('#tree_menu').show();
    $('.categoryTree').show();
  }, function () {
    // あえて何も記述しない
  });
});
  • スタイルシートではcategoryTree,categoryTree-child,categoryTree-grandchilddisplay: none;で隠してます。

ここまでで最初のカテゴリーが表示されます。

次にそれぞれのカテゴリーのボタンにカーソルが来た時に子を表示、子のところに来たら孫を表示させるため、上記の下の行にajax通信を行う記述を追記します。

app/assets/javascripts/categories.js
$(document).ready(function () {

// 省略

function childBuild(children) {
    let child_category = `
                        <li class="category_child">
                          <a href="/categories/${children.id}"><input class="child_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return child_category;
  }

  function gcBuild(children) {
    let gc_category = `
                        <li class="category_grandchild">
                          <a href="/categories/${children.id}"><input class="gc_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return gc_category;
  }

// 子カテゴリーを表示
  $('.parent_btn').hover(function () {
    let categoryParent = $(this).attr('name');
    $.ajax({
        url: '/products/search',
        type: 'GET',
        data: {
          parent_id: categoryParent
        },
        dataType: 'json'
      })
      .done(function (data) {
        $(".categoryTree-grandchild").hide();
        $(".category_child").remove();
        $(".category_grandchild").remove();
        $('.categoryTree-child').show();
        data.forEach(function (child) {
          let child_html = childBuild(child)
          $(".categoryTree-child").append(child_html);
        });
      })
      .fail(function () {
        alert("カテゴリーを選択してください");
      });
  }, function () {
        // あえて何も記述しない
  });

  // 孫カテゴリーを表示
  $(document).on({
    mouseenter: function () {
      let categoryChild = $(this).attr('name');
      $.ajax({
          url: '/products/search',
          type: 'GET',
          data: {
            children_id: categoryChild
          },
          dataType: 'json'
        })
        .done(function (gc_data) {
          $(".category_grandchild").remove();
          $('.categoryTree-grandchild').show();
          gc_data.forEach(function (gc) {
            let gc_html = gcBuild(gc)
            $(".categoryTree-grandchild").append(gc_html);
          });
        })
        .fail(function () {
          alert("カテゴリーを選択してください");
        });
    },
    mouseleave: function () {
      // あえて何も記述しない
    }
  }, '.child_btn');
});

  • 補足 : ここで、子カテゴリーと孫カテゴリーの表示の記述が結構違うところがあります。 これは動的に追加した要素(子カテゴリー)に対しては記述のやり方を変える必要があります。 例えばclickchangeアクションの様に
// 例
  $(document).on('hover', "#hoge", function () {
    // 実行内容の記述
  });

上記の様に記述すれば動きそうですが、実はhoverアクションでは動きません。
(これに気付くまで時間がかかってしまった。)

hoverなどのマウスイベントについてちょっと理解が必要になります。

【参考サイト】
jQueryのhover()でマウスオーバーの処理
mouseenterとmouseoverの違い

それではajax通信を行ってきます。
まず、先ほど設定したコントローラーのsearchに各ボタンに割り振ってあるnameの値(id)をパラメーターとして取得してきます。
受け取ったパラメータからそれぞれの子カテゴリー、孫カテゴリーを配列で受け取ります。

app/controllers/categories_controller.rb
 # 省略

  def search
    respond_to do |format|
      format.html
      format.json do
        if params[:parent_id]
          @childrens = Category.find(params[:parent_id]).children
        elsif params[:children_id]
          @grandChilds = Category.find(params[:children_id]).children
        end
     end
  end

 # 以下省略

受け取った配列情報をそれぞれjbuilderでjson形式にします。

app/views/categories/search.json.jbuilder
json.array! @childrens do |child|
  json.id child.id
  json.name child.name
end
json.array! @grandChilds do |gc|
  json.id gc.id
  json.name gc.name
end

ここまで上手くいけば、イベント発火時に無事、.doneの処理に行くことができます。
子カテゴリー、孫カテゴリーが切り替わる様になれば一旦は成功です。

表示したカテゴリーを非表示にする処理

それでは非表示にする処理を追記していきます。

app/assets/javascripts/categories.js
$(document).ready(function () {

// 省略

// カテゴリーを非表示
  $(document).on({
    mouseleave: function (e) {
      e.stopPropagation();
      e.preventDefault();
      $(".categoryTree-grandchild").hide();
      $(".categoryTree-child").hide();
      $(".categoryTree").hide();
      $(this).hide();
      $(".category_child").remove();
      $(".category_grandchild").remove();
    },
    mouseenter: function () {
    }
  }, '#tree_menu');

問題点

一旦これで上手くいきそうですが、実は問題点があります。

  1. 現状だと#tree_menuの子要素が変化することにより#tree_menu自身のボックスの大きさが変化してカーソルがボタンが外れても非表示にならないところが出てきます。
  2. また、子の高さが小さいと子を選択する前に消えたり、動かすたびに子カテゴリーの表示が変わったりしてしまいます。
  3. 子カテゴリー選択すると、どの親から来たのかわからない。

実に不安定な挙動です。

問題点の解決

今回、この問題を解決する方法として行った作業は

  1. 子要素を表示した後に親の要素(#tree_menu)のボックスの大きさを一番上の親カテゴリーの要素の大きさにに変えてやること。
  2. マウスが当たっている判定を一定時間後に行うこと。
  3. 子カテゴリーを選択したらその親も表示させる。

まずは、1番目。

気付けば、処理の方法は簡単ですが、気付くまで時間がかかった。
試しに大きさを揃えて固定にしようとしたが、そうすると見た目が好みじゃないので固定は却下しました。

  • やり方は.done処理後にcssを変えるだけです。

次に、2番目。

メルカリでは
各ボックスの大きさを揃えてカーソルを上下に動かさず、横にずらして子カテゴリーを選択しています。しかし、使い勝手がいいと思えない。

  • そこで、setTimeout()clearTimeout()を駆使することで処理を実行するか否かを判断して解決できそう。 ついでにカテゴリーボタンの周りの挙動も手を加えます。

最後に3番目。

子と孫のボタンのnameからそれぞれの親のidを取得して該当する要素を見つけて表示を変えるだけです。

  • コントローラーに孫ボタンのパラメータの処理を追記。jbuilderにroot.idparent.idの取得も追記します。親に戻ったら指定したcssを外せば元に戻ります。


最終的なコードを確認したい方はこちら
app/controllers/categories_controller.rb
 class CategoriesController < ApplicationController
  before_action :set_parents

  def search
    respond_to do |format|
      format.html
      format.json do
        if params[:parent_id]
          @childrens = Category.find(params[:parent_id]).children
        elsif params[:children_id]
          @grandChilds = Category.find(params[:children_id]).children
        elsif params[:gcchildren_id]
          @parents = Category.where(id: params[:gcchildren_id])
        end
     end
  end

  private

  def set_parents
    @parents = Category.where(ancestry: nil)
  end
end

app/views/categories/search.json.jbuilder
json.array! @childrens do |child|
  json.id child.id
  json.name child.name
end
json.array! @grandChilds do |gc|
  json.id gc.id
  json.name gc.name
  json.root gc.root_id
end
  json.array! @parents do |parent|
  json.parent parent.parent_id
end
app/assets/javascripts/categories.js
$(document).ready(function () {
  // 非同期にてヘッダーのカテゴリーを表示
  function childBuild(children) {
    let child_category = `
                        <li class="category_child">
                          <a href="/categories/${children.id}"><input class="child_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return child_category;
  }

  function gcBuild(children) {
    let gc_category = `
                        <li class="category_grandchild">
                          <a href="/categories/${children.id}"><input class="gc_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return gc_category;
  }

  // 親カテゴリーを表示
  $('#categoBtn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    timeOut = setTimeout(function () {
      $('#tree_menu').show();
      $('.categoryTree').show();
    }, 500)
  }, function () {
    clearTimeout(timeOut)
  });

  // 子カテゴリーを表示
  $('.parent_btn').hover(function () {
    $('.parent_btn').css('color', '');
    $('.parent_btn').css('background-color', '');
    let categoryParent = $(this).attr('name');
    timeParent = setTimeout(function () {
      $.ajax({
          url: '/products/search',
          type: 'GET',
          data: {
            parent_id: categoryParent
          },
          dataType: 'json'
        })
        .done(function (data) {
          $(".categoryTree-grandchild").hide();
          $(".category_child").remove();
          $(".category_grandchild").remove();
          $('.categoryTree-child').show();
          data.forEach(function (child) {
            let child_html = childBuild(child)
            $(".categoryTree-child").append(child_html);
          });
          $('#tree_menu').css('max-height', '490px');
        })
        .fail(function () {
          alert("カテゴリーを選択してください");
        });
    }, 400)
  }, function () {
    clearTimeout(timeParent);
  });

  // 孫カテゴリーを表示
  $(document).on({
    mouseenter: function () {
      $('.child_btn').css('color', '');
      $('.child_btn').css('background-color', '');
      let categoryChild = $(this).attr('name');
      timeChild = setTimeout(function () {
        $.ajax({
            url: '/products/search',
            type: 'GET',
            data: {
              children_id: categoryChild
            },
            dataType: 'json'
          })
          .done(function (gc_data) {
            $(".category_grandchild").remove();
            $('.categoryTree-grandchild').show();
            gc_data.forEach(function (gc) {
              let gc_html = gcBuild(gc)
              $(".categoryTree-grandchild").append(gc_html);
              let parcol = $('.categoryTree').find(`input[name="${gc.root}"]`);
              $(parcol).css('color', 'white');
              $(parcol).css('background-color', '#b1e9eb');
            });
            $('#tree_menu').css('max-height', '490px');
          })
          .fail(function () {
            alert("カテゴリーを選択してください");
          });
      }, 400)
    },
    mouseleave: function () {
      clearTimeout(timeChild);
    }
  }, '.child_btn');

  // 孫カテゴリーを選択時
  $(document).on({
    mouseenter: function () {
      let categoryGc = $(this).attr('name');
      timeGc = setTimeout(function () {
        $.ajax({
            url: '/products/search',
            type: 'GET',
            data: {
              gcchildren_id: categoryGc
            },
            dataType: 'json'
          })
          .done(function (gc_result) {
            let childcol = $('.categoryTree-child').find(`input[name="${gc_result[0].parent}"]`);
            $(childcol).css('color', 'white');
            $(childcol).css('background-color', '#b1e9eb');
            $('#tree_menu').css('max-height', '490px');
          })
          .fail(function () {
            alert("カテゴリーを選択してください");
          });
      }, 400)
    },
    mouseleave: function () {
      clearTimeout(timeGc);
    }
  }, '.gc_btn');


  // カテゴリー一覧ページのボタン
  $('#all_btn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    $(".categoryTree-grandchild").hide();
    $(".categoryTree-child").hide();
    $(".category_grandchild").remove();
    $(".category_child").remove();
  }, function () {
    // あえて何も記述しないことで親要素に外れた際のアクションだけを伝搬する
  });

  // カテゴリーを非表示(カテゴリーメニュから0.8秒以上カーソルを外したら消える)
  $(document).on({
    mouseleave: function (e) {
      e.stopPropagation();
      e.preventDefault();
      timeChosed = setTimeout(function () {
        $(".categoryTree-grandchild").hide();
        $(".categoryTree-child").hide();
        $(".categoryTree").hide();
        $(this).hide();
        $('.parent_btn').css('color', '');
        $('.parent_btn').css('background-color', '');
        $(".category_child").remove();
        $(".category_grandchild").remove();
      }, 800);
    },
    mouseenter: function () {
      clearTimeout(timeChosed);
    }
  }, '#tree_menu');

  // カテゴリーボタンの処理
  $(document).on({
    mouseenter: function (e) {
      e.stopPropagation();
      e.preventDefault();
      timeOpened = setTimeout(function () {
        $('#tree_menu').show();
        $('.categoryTree').show();
      }, 500);
    },
    mouseleave: function (e) {
      e.stopPropagation();
      e.preventDefault();
      clearTimeout(timeOpened);
      $(".categoryTree-grandchild").hide();
      $(".categoryTree-child").hide();
      $(".categoryTree").hide();
      $("#tree_menu").hide();
      $(".category_child").remove();
      $(".category_grandchild").remove();
    }
  }, '.header__headerInner__nav__listsLeft__item');
});


まとめ

以上で、追加条件を満たした実装ができました。
コードの書き方はもっとスマートなやり方があるかもしれません。
もし、間違ったところあればコメントにてアドバイスいただければ幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Could not load the 'listen' gem. Add `gem 'listen'` to the development group of your Gemfile (LoadError)というエラーの解決法について

Could not load the 'listen' gem. Add gem 'listen' to the development group of your Gemfile (LoadError)というエラーの解決法について

原因は、gemのlistenが開発環境にあることにある。
つまり、
gem 'listen'をdevelopmentだけではなく、全体に影響させればいいので、gemfileの最後に記述するとかして、bundle install bundle updateすれば、

解決するだろう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitHubに100MB超えのファイルをプッシュしてエラーになった

Railsアプリを開発中に、GitHubにプッシュできなくなりました。
GitHubに100MB制限があることを知らず、少し苦戦しましたので備忘録として。

エラー

いつもどおり、git add git comit git push と進めるとエラーが出ました。
development.logが100MBを超えてますよ、ということです。

・・・
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
remote: error: Trace: 13ec04b062a0cd9bb1a20e6a0b921ec7cf7396c0
remote: error: See http://git.io/iEPt8g for more information.
remote: error: File log/development.log is 150.13 MB; this exceeds GitHub's file size limit of 100.00 MB
・・・

100MB超ファイルをアップする方法もあるようですが、今回はLogファイルだったので削除する方針で進めます。

100MB超ファイルをプッシュする方法(参考)
https://qiita.com/kanaya/items/ad52f25da32cb5aa19e6

解決手順

  1. 巨大ファイルのコミットを取り消す
  2. 巨大ファイルを削除(Gitから除外)
  3. 再度コミット
  4. 再度プッシュ

1.巨大ファイルのコミットを取り消す

直前のコミットであればこれで取り消せます。


$ git reset --hard HEAD

直前ではないときは、該当するリビジョンを調べてその1個前まで戻ります。

$ git log
コミット履歴がズラッと出るので、該当リビジョンを確認

commit 13ec04b062a0cd9bb1a20e6a0b921ec7cf7396c0
↑これが該当リビジョンの1個前

$ git reset --soft 13ec04b062a0cd9bb1a20e6a0b921ec7cf7396c0

これで巨大ファイルのコミットの前に戻りました。

2.巨大ファイルを削除(Gitから除外)

単純に巨大ファイルを削除(中身を空に)してもよいのですが、Railsの仕様上いずれまた100MBを超えることになるのでGitから除外します。

.gitignoreでGitから除外する

.gitignoreに除外ファイルを追加します。

.gitignore
/log/development.log

.gitignore 書き方(参考)
https://qiita.com/inabe49/items/16ee3d9d1ce68daa9fff

最初から除外したほうが楽
このツールで、各言語に最適化された.gitignoreがゲットできます。
https://www.toptal.com/developers/gitignore

3.再度コミット

あとはいつもどおりと言いたいところですが、ちゃんと除外されているか確認しましょう。
自分はここで何度もやり直しました。

除外してもコミットされてしまうときは

Gitのキャッシュが残っている場合は、こちらを参考にキャッシュを削除してください。
(ちなみにVS CODEだと、除外ファイルはグレーアウトされます。)
https://qiita.com/fuwamaki/items/3ed021163e50beab7154

4.再度プッシュ

最後にプッシュすればコンプリートです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

検索機能の実装

検索フォームを作成

検索の入力欄とボタンには、フォームを使います。

app/views/tweets/index.html.erb

<%= form_with(url: search_tweets_path, local: true, method: :get, class: "search-form") do |form| %>
  <%= form.text_field :keyword, placeholder: "投稿を検索する", class: "search-input" %>
  <%= form.submit "検索", class: "search-btn" %>
<% end %>

searchアクションのルーティングを設定

collection do
  get 'search'
end

を以下のように追加

config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  root to: 'tweets#index'
  resources :tweets do
    resources :comments, only: :create
    collection do
      get 'search'
    end
  end
  resources :users, only: :show
end

検索するメソッドをTweetモデルに定義

def self.search(search)
 if search
   Tweet.where('text LIKE(?)', "%#{search}%")
 else
   Tweet.all
 end
end

を以下のように追加

app/models/tweet.rb

class Tweet < ApplicationRecord
  validates :text, presence: true
  belongs_to :user
  has_many :comments

  def self.search(search)
    return Tweet.all unless search
    Tweet.where('text LIKE(?)', "%#{search}%")
  end
end

searchアクションをコントローラーに定義

コントローラーに基本の7つのアクションには含まれない、searchアクションを定義します。

app/controllers/tweets_controller.rb

before_action :move_to_index, except: [:index, :show, :search]

def search
 @tweets = Tweet.search(params[:keyword])
end

Tweetモデルに書いたsearchメソッドを呼び出しています。seachメソッドの引数にparams[:keyword]と記述して、検索結果を渡しています。
また、未ログイン状態にトップページへリダイレクトされてしまうことを回避するため、before_actionのexceptオプションに:searchを追加しています。

検索結果画面のビューを作成しよう

app/views/tweetsディレクトリにsearch.html.erbを作成する。

検索結果を表示するように記述します。

app/views/tweets/search.html.erb

<%= form_with(url: search_tweets_path, local: true, method: :get, class: "search-form") do |form| %>
  <%= form.text_field :keyword, placeholder: "投稿を検索する", class: "search-input" %>
  <%= form.submit "検索", class: "search-btn" %>
<% end %>
<div class="contents row">
  <% @tweets.each do |tweet| %>
    <%= render partial: "tweet", locals: { tweet: tweet } %>
  <% end %>
</div>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】deviseで行うアクセス制限にエラーメッセージを表示させる。

はじめに

deviseを使っていると必ずと言っていいほど使うアクセス制限をかけるためのヘルパーメソッドauthenticate_user!。
制限をかけた際にログイン画面にページ遷移しますが、何も設定をしないとなぜページ遷移したかわかりにくいので「ログインして下さい。」等々エラーメッセージを表示させた方が、ユーザー目線を考慮したサイトにすることができます。

ポートフォリオを作成していた際に、そんな風に思い組み込んだことを備忘録として書き留めておきます。
①エラーメッセージの表示方法
②エラーメッセージの日本語化

前提

・gem deviseを使用している。
・before_action :authenticate_user!を使用したいコントローラに記述している。
【参考サイト】
Rails deviseで使えるようになるヘルパーメソッド一覧

①エラーメッセージの表示方法

authenticate_user!を使用した際に「ログインもしくはサインアップが必要です」のようなメッセージを表示させたい際は、下記記述を任意のviewにしてあげれば出ます。

application.html.erb
<%= alert %>

上記記述をしてauthenticate_user!が正常に動作した場合、
以下devise.en.ymlの17行目あたりに記載してあるエラー文が出るはずです。

config/locales/devise.en.yml
unauthenticated: "You need to sign in or sign up before continuing."

deviseには元々エラー文章が用意されているようなので、
あとはそれを呼び出すための記述をしてあげれば良いわけですね。
簡単簡単!

『...余談』
<%= notice %>を使用すればログイン時に「ログインしました」的な文章を出すことも可能です。
簡単ですね。

②エラーメッセージの日本語化

devise.ja.ymlの導入

先ほどはdevise.en.ymlの中から文章を引っ張ってました。
今回は日本語化するためにdevise.ja.ymlを導入していきます。
(en=english / ja=japaneseの意)

config/localesにdevise.ja.ymlファイルを作成し、
中身はdevise-i18n/ja.yml at master - GitHubからコピペして保存。

gem 'rails-i18n'のインストール

Gemfile
gem 'rails-i18n'

上記記述をして、bundle installを実行。
インストールはこれで完了です。

config/application.rbに記述を追加

config/application.rb
config.i18n.default_locale = :ja
config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s]

1行目だけでも機能する場合もありますが、しない時もあるらしく、
2行目は必要に応じて記述してください。

以上で、日本語化は完了。

【参考】
[Rails5] deviseの日本語化について(devise.ja.ymlを入れても効かないとき)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsアプリで時間を表示したときの「UTC」を消したい(Railsのタイムゾーンの扱い方)

プログラミングの勉強日記

2020年8月12日 Progate Lv.226

目標

 Railsアプリで投稿を更新した時間を表示させるようにした。

views/posts/index.html.erb
<%= post.updated_at %>

投稿を更新した日時を表示することができたが、時間の後ろに『UTC』という文字列が入ってしまっている。
『2020-08-08 11:56:59 UTC』

これを消して、以下のように表示させたい!!
『2020-08-08 11:56:59』

方法

 RubyにはTime::strftimeメソッドがあり、これで時刻を好きなフォーマットに変更できる。

views/posts/index.html.erb
 <%= post.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>

これでUTCが消され、以下のように表示されるようになった。
『2020-08-08 11:56:59』

対応する日時 フォーマットの指定子
%Y 年(year)
%m 月(month)
%d 日にち(day)
%H 時間(hour)
%M 分(minutes)
%S 秒(seconds)

なので、このように自由にフォーマットを変えることができる。

views/posts/index.html.erb
 <%# 2020-08-08 11:56 %>
 <%= post.updated_at.strftime("%Y-%m-%d %H:%M") %>

 <%# 2020年 08月 08日 %>
 <%= post.updated_at.strftime("%Y年 %m月 %d日") %>

UTCとは

 UTCは協定世界時のことである。国や地域ごとに時差があり、タイムゾーンと呼ばれる共通の時間を使うエリアの一帯のことを指す。日本で使われている時間は日本標準時(JST)と呼ばれている。
 RailsではデフォルトでUTCが設定されている。

UTCの変更方法

 Railsのタイムゾーンはconfig/application.rbで設定できる。config.time_zoneconfig.active_record.default_zimezoneの2つの方法でタイムゾーンを変更することができる。
 config.time_zoneは、Time.zone.nowやTime.currentといったTimeWithZoneに対する設定で、"Tokyo"などの都市名を設定することでタイムゾーンを変更できる。
 config.active_record.default_zimezoneはデータベースレコードの読み書きに対する設定で、:utc:localのどちらかを設定することができる。:localを使用すると、データベースが動作するサーバーのタイムゾーンが使用される。

config/application.rb
config.time_zone = "Tokyo"
config.active_record.default_timezone = :local

application.rbを変更した後、Railsサーバーを再起動する!!

参考文献

Timeメソッドで出てくるタイムゾーンの UTC とかいう文字を消したい
Railsでタイムゾーンを扱う方法を現役エンジニアが解説【初心者向け】

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】もう迷いたくない。モデル、テーブル、コントローラ、ビューの新規作成。

はじめに

この記事はRailsアプリを立ち上げた後の作業をまとめたものです。
何らかの機能を作る際には、モデルを作ってコントローラ作ってルーティング書いて...といった作業が頻発します。
毎回構文を忘れてしまうので、一連の流れをまとめました。

コントローラの作成

まずはコントローラの作成です。
今回は画像を保存する機能を作るのでimagesとします。

rails g controller images

モデルの作成

下記のコマンドでimageモデルを作りましょう!

rails g model image

これでimageモデルが作成されたと思います。
必要であれば、モデルにバリデーションやアソシエーションを記述しましょう。

テーブル作成

モデルを作成した際にマイグレーションファイルも作成されたと思います。
このファイルを編集して主キーやnull制約などの設定を行います。
私の場合は、以下のようにして”filename”を主キーにし、null制約をかけています。
主キーにしたのでそんな制約要らないかもしれませんが...

class CreateImages < ActiveRecord::Migration[5.2]
  def change
    create_table :images, id: false, primary_key: :filename do |t|
      t.string :filename, null: false
      t.timestamps
    end
  end
end

それではマイグレートを実行し、正常終了すればテーブルが作成されます!

rails db:migrate

ビューの作成

画像の一覧を表示するページということで、index.html.hamlを作りましょう。
これまでの作業でimagesディレクトリが作成されているので、そこに作成します。
ビューの内容については、今回の記事で詰めるところではないので適当に"HelloWorld"を出しておきます。

%h1
  HelloWorld

ルーティング

ここまで来ればほぼ出来たようなものです。
routes.rbにimagesのルーティングを記載します。
今回はresourcesメソッドを使用してindexアクションのみを設定しました。

Rails.application.routes.draw do
  resources :images, only: [:index]
end

結果

出来ました!これで仕事が捗る...!

スクリーンショット 2020-08-12 4.25.04.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails Tutorialを咀嚼する【第1章ゼロからデプロイまで】後半

1.4 Gitによるバージョン管理

■Git
バージョン管理ツール。Gitで都度都度作業を保存することで、やり直しができたりする。
addしてからcommitしてpushする。

■Bitbucket
git専門のホスティングサービス。GitHubの仲間。

1.5 デプロイする

■Heroku
Paas。FFFTPとか使わず、開発したものをサーバーにアップロードすることができる。SQLiteが使えないため、若干厄介。

【演習】
1も2も大したことないので省略。

■('a'..'z').to_a.shuffle[0..7].join
ランダムなアルファベット8文字を表示してくれる。
aからzのアルファベットを配列に格納。
配列の順番をシャッフルする。
0から7番目(8個)まで取り出す。
結合して文字列にする。

【演習】
1も2も省略。

分ける必要はなかった。後で結合する。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む