has_manyの数を制限

has_manyする側ではなく、される側のモデルにバリデーションを設定する

class Day < ApplicationRecord
  MAX_DAYS_COUNT = 14

  belongs_to :article

  validate :days_count_must_be_within_limit

  private

  def days_count_must_be_within_limit
    errors.add(:base, "days count limit: #{MAX_DAYS_COUNT}")
    if article.days.count >= MAX_DAYS_COUNT
  end
end


参考記事:【Rails】has_manyの数を制限するvalidate - Qiita

Vue + ActiveStorageで複数画像投稿

①ActiveStorageをインストール
$ bundle exec rails active_storage:install
$ bundle exec rails db:migrate


②モデルを設定
class Block < ApplicationRecord
  has_many_attached :images

  def images_url
    if images.attached?
      images_array = []

      images.each do |image|
        url = Rails.application.routes.url_helpers.rails_blob_path(image, only_path: true)
        images_array.push(url)
      end

      images_array
    else
      []
    end
  end
end


③コントローラーを設定
class Api::BlocksController < ApplicationController
  def create
    block = Block.new(block_params)

    if block.save
      render json: block
    else
      render json: block.errors, status: :bad_request
    end
  end

  private

  def block_params
    params.require(:block).permit(:title, :comment, images: [])
  end
end


コンポーネントを設定
<template>
  <div>
    <ValidationObserver
      v-slot="{ handleSubmit }"
      ref="observer"
    >

      <ValidationProvider
        v-slot="{ errors }"
        rules="required|max:100"
      >
        <p>タイトル</p>
        <input
          v-model="block.title"
          name="タイトル"
        >
        <span class="text-danger">{{ errors[0] }}</span>
      </ValidationProvider>
      
      <ValidationProvider
        v-slot="{ errors }"
        rules="max:300"
      >
        <p>コメント</p>        
        <textarea
          v-model="block.comment
          name="メモ"
        />
        <span class="text-danger">{{ errors[0] }}</span>
      </ValidationProvider>
          
      <ValidationProvider
        v-slot="{ errors }"
        ref="provider"
        rules="image"
      >
        <p>写真</p>
        <input
          @change="handleChange"
          type="file"
          multiple="multiple"
          accept="image/png, image/jpeg"
          name="写真"
        >
        <div
          v-for="(image, index) in previewImages"
          :key="index"
        >
          <img :src="image">
        </div>
        <span class="text-danger">{{ errors[0] }}</span>
      </ValidationProvider>

      <button @click="addBlock">
        ブロックを追加
      </button>

    </ValidationObserver>
  </div>
</template>

<script>
export default {
  data() {
    return {
      block: {
        title: '',
        comment: '',
        uploadImages: []
      },
      previewImages: []
    }
  },
  methods :{
    async handleChange(event) {
      const { valid } = await this.$refs.provider.validate(event)
      if (valid) {
        for (let file of event.target.files) {
          this.previewImages.push(URL.createObjectURL(file))
          this.block.uploadImages.push(file)
        }
      }
    },
    async addBlock() {
      const formData = new FormData()
      formData.append('block[title]', this.block.title)
      if (this.block.comment) {
        formData.append('block[comment]', this.block.comment)
      }
      if (this.block.uploadImages.length) {
        for (let image of this.block.uploadImages) {
          formData.append('block[images]' + '[]', image)
        }
      }
      await this.$axios.post('blocks', formData)
        .catch(err => console.log(err.response))
    }
  }
}
</script>



参考記事: axoisにFormDataを使ってArrayデータを送信する - Qiita

kaminari と vue-infinite-loading で無限スクロール

①kaminariをインストール
gem 'kaminari'
$ bundle install


②kaminariのメソッドを定義
#app/controllers/pagination.rb

module Pagination
  class Api::ArticlesController < ApplicationController
    def resources_with_pagination(resources)
      {
        pagenation: {
          current: resources.current_page,
          previous: resources.prev_page,
          next: resources.next_page,
          limit_value: resources.limit_value,
          pages: resources.total_pages,
          count: resources.total_count
        }
      }
    end
  end
end


③スクロールさせたいデータとkaminariの情報をjson
#app/controllers/api/articles_controller.rb

class Api::ArticlesController < ApplicationController
  include Pagination

  def index
    articles = Article.all.page(params[:page]).per(10).order(created_at: :desc)
    pagination = resources_with_pagination(articles)
    @articles = articles.as_json
    render json: { articles: @articles, kaminari: pagination }
  end
end


④vue-infinite-loadingをインストール
$ yarn add vue-infinite-loading
// app/javascript/plugins/InfiniteLoading.js

import Vue from 'vue'
import InfiniteLoading from 'vue-infinite-loading'

Vue.use(InfiniteLoading, {
 slots: {
   noMore: 'すべて読み込みました',
   noResults: '読み込み完了しています'
 }
})
// app/javascript/packs/hello_vue.js

import InfiniteLoading from '../plugins/InfiniteLoading'

Vue.config.productionTip = false

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    render: h => h(App)
  }).$mount()
  document.body.appendChild(app.$el)
})


コンポーネントに設定を書く
<template>
  <div>
    <div
      v-for="article in articles"
      :key="article.id"
    >
      <ArticleList :article="article" />
    </div>
    <infinite-loading
      spinner="circles"
      @infinite="infiniteHandler"
    />
  </div>
</template>

<script>
import ArticleList from './components/index/ArticleList'

export default {
  components: {
    ArticleList
  },
  data() {
    return {
      articles: [],
      page: 1
    }
  },
  methods: {
    infiniteHandler($state) {
      this.$axios.get('articles', { params: { page: this.page }})
        .then(res => {
          setTimeout(() => {
            if (this.page <= res.data.kaminari.pagenation.pages) {
              this.page += 1
              this.articles.push(...res.data.articles)
              $state.loaded()
            } else {
              $state.complete()
            }
          }, 800)
        })
        .catch(err => {
          $state.complete()
        })
    }
  }
}
</script>



参考記事:
【Rails6】kaminariでページネーションしてVue.jsで無限スクロール(vue-infinite-loading)を導入する。 - Qiita

bundle installの際のtzinfo-dataのエラー

bundle installの際に下記エラーが発生

tzinfo-data is not present. please add gem 'tzinfo-data' to your gemfile and run bundle install (tzinfo::datasourcenotfound)


platformsオプションを削除してbundle updateで解決

gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

↓

gem 'tzinfo-data' 
$ bundle update



参考記事:
bundle installする際のtzinfo-dataのwarningがウザい - Qiita
The dependency tzinfo-data〜 – Ruby on Rails 5 アプリケーションプログラミング読解物語

textareaの高さを入力内容に合わせて自動調整

<template>
  <div class="form-group">
    <textarea     
      v-model="description"
      ref="area"
      :style="styles"
      class="form-control"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      description: '',
      height: ''
    }
  },
  computed: {
    styles(){
      return {
        'height': this.height
      }
    }
  },
  watch: {
    description: {
      this.resize()
    }
  },
  created() {
    this.resize()
  },
  methods: {
    resize(){
      this.height = 'auto'
      this.$nextTick(()=>{
        this.height = this.$refs.area.scrollHeight + 'px'
      })
    }
  }
}
</script>


参考記事:
textareaの高さを自動で変える方法 Vue.js編 | アールエフェクト

パスワードの表示非表示の切り替え

<template>
  <div class="input-group mt-4">
    <input
      v-model="user.password"
      name="パスワード"
      :type="inputType"
      class="form-control"
    >
    <div class="input-group-append">
      <div class="input-group-text">
      
        <template v-if="inputType == 'password'">
          <span @click="showPassword">
            <font-awesome-icon
              :icon="['far', 'eye']"
              class="fa-lg"
            />
          </span>
        </template>

        <template v-else>
          <span @click="hidePassword">
            <font-awesome-icon
              :icon="['far', 'eye-slash']"
              class="fa-lg"
             />
           </span>
         </template>

      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: { password: '' },
      inputType: 'password',
    }
  },
  methods: {
    showPassword() {
      this.inputType = 'text'
    },
    hidePassword() {
      this.inputType = 'password'
    }
  }
}
</script>

Image from Gyazo Image from Gyazo
目のアイコンをクリックすることでinput要素のtype属性をtextに、
目にスラッシュのアイコンをクリックすることでinput要素のtype属性をpasswordに切り替える。

子コンポーネント強制再描画

v-ifを利用する
<template>
  <div>
    <button @click="showChild">
      Click Here
    </button>
    <Child v-if="isVisibleChild" />
  </div>
</template>

<script>
import Child from "./Child.vue";

export default {
  components: {
    Child
  },
  data() {
    return {
      isVisibleChild: true
    }
  },
  methods: {
    showChild() {
      this.isVisibleChild = false
      this.$nextTick(() => (this.isVisibleChild = true))
    }
  }
}
</script>

単純に true と false の切り替えだと再計算されないため、 nextTickを利用してDOMの更新サイクル後に子コンポーネントを再計算させる。


参考記事:
Vue.js 子コンポーネントを強制的に再描画するいくつかの方法 - tomatoaiu の Tech Blog