RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法2(Stimlus化)

RailsのTurboでAjaxで要素を追加したときにCSSアニメーションさせる方法 では、application.js に記載していましたが、これをStimulus化して利用しやすくしてみます。

やってみる

stream_animations_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    document.addEventListener("turbo:before-stream-render", this.animate)
  }

  disconnect() {
    document.removeEventListener("turbo:before-stream-render", this.animate)
  }

  animate(e) {
    const turboStreamElm = e.target
    const { action, target } = turboStreamElm

    if (action === "remove") {
      const turboFrameElm = document.getElementById(target)
      const { leavingClass } = turboFrameElm.dataset
      if (leavingClass) {
        e.preventDefault()
        turboFrameElm.classList.add(leavingClass)
        turboFrameElm.addEventListener("animationend", () => {
          console.log("aaa");
          e.target.performAction()
        })
      }
    }
  }
}

index.js

registerで追加。

import StreamAnimationsController from "./stream_animations_controller"
application.register("stream-animations", StreamAnimationsController)

html

data-controllerで指定するだけ。

      .images{"data-controller": "stream-animations" }
        = render partial: "image", collection: @user.images, locals: { user: @user }

See Also

RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法
RailsのHotwireでCSSアニメーションさせる方法(登録処理もTurbo/Stimlusで実装)

RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法

Ajaxでデータ追加・削除するのにRailsのTurbo便利ですよね。
でも、CSSアニメーションが効かなくて困ってました。

Turbo以前

image-wrapperクラス以下に画像とxボタンを配置します。

# haml
.image-wrapper.position-relative
  = image_tag image
  = link_to user_image_path(user, image), remote: true, data: { method: :delete, confirm: "Can I delete it?" }, class: "delete-image-link position-absolute top-0 end-0" do
    %span ×

cssはfade-out用にkeyframes使ってアニメーションを定義します。

.fade-out {
  animation: fade-out .4s linear;
  opacity: 0;
}
@keyframes fade-out {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

あとはjsでfade-outクラスを追加するだけでアニメーションが可能でした。

  const deleteImageLinks = document.querySelectorAll(".delete-image-link")
  if (deleteImageLinks.length) {
    deleteImageLinks.forEach((link) => {
      link.addEventListener("click", (e) => {
        e.preventDefault
        e.target.closest(".image-wrapper").classList.add("fade-out")
      })
    })
  }

まずはTurboで実装

htmlは、turbo_frame_tag で更新部分を囲います。ついでにdata属性はturbo用に書き換えます。

# haml
= turbo_frame_tag "user_image_#{image.id}" do
  .image-wrapper.position-relative
    = image_tag image
    = link_to user_image_path(user, image), remote: true, data: { turbo_method: :delete, turbo_confirm: "Can I delete it?" }, class: "delete-image-link position-absolute top-0 end-0" do
      %span ×

controllerでは、render turbo_stream で削除します。

# controller
class Users::ImagesController < ApplicationController
  def destroy
    user = User.find(params[:user_id])
    user.images.find(params[:id]).purge

    render turbo_stream: turbo_stream.remove("user_image_#{params[:id]}")
  end
end

自動で更新はできましたが、これではcssアニメーションを追加することができません。

ではどうするか

turbo:before-stream-render という、turboの更新前のイベントを利用し、一時的に処理を止めcss animationが終わってから処理を再開することで対応します。

htmlに大きな違いはありませんが、jsからアニメーション用のクラス名を取得できるようにdata属性に leaving_class を指定しておきます。

# slim
-# ※ data属性に leaving_class を用意する
= turbo_frame_tag "user_image_#{image.id}", data: { leaving_class: "fade-out" } do
  .image-wrapper.position-relative
    = image_tag image
    = link_to user_image_path(user, image), remote: true, data: { turbo_method: :delete, turbo_confirm: "Can I delete it?" }, class: "delete-image-link position-absolute top-0 end-0" do
      %span &times;

turbo:before-stream-render イベントで、以下のようにすることで対応できました。

document.addEventListener("turbo:before-stream-render", (e) => {
  const turboStreamElm = e.target
  const { action, target } = turboStreamElm

  if (action === "remove") {
    const turboFrameElm = document.getElementById(target)
    const { leavingClass } = turboFrameElm.dataset
    if (leavingClass) {
      e.preventDefault()
      turboFrameElm.classList.add(leavingClass)
      turboFrameElm.addEventListener("animationend", () => {
        e.target.performAction()
      })
    }
  }
})

所感

正直Turbo以前の方がsimpleですよね。

参考

Tweet / Twitter
Turbo Streamsでの削除前にアニメーションを入れる

See Also

RailsのTurboでAjaxで要素を削除したときにCSSアニメーションさせる方法2(Stimlus化)RailsのHotwireでCSSアニメーションさせる方法(登録処理もTurbo/Stimlusで実装)

今更だけどRailsの基本認証について

手元で作ったRailsアプリを軽くデプロイして確認したかったので、基本認証入れて確認しようとしていましたが、 似たようなメソッドがいくつもあってどれだっけとなったり、良くない実装が検索結果に出てきたので、改めて整理しておきます。

ググる

以下のようなコードが紹介されています。

class ApplicationController < ActionController::Base
  before_action :basic_auth

  def basic_auth
    authenticate_or_request_with_http_basic do |username, password|
      username == ENV["BASIC_AUTH_USER"] && password == ENV["BASIC_AUTH_PASSWORD"]
    end
end

== での比較は timing attacks の脆弱性があります。

secure_compare使おう

secure_compare を使うというのが解なのですが、こちらの記事がめちゃくちゃ詳しく載っててるので割愛します。
 
参考)機密情報に関わる文字列の比較は == ではなく secure_compare を使おう

やり方おさらい

やり方1. http_basic_authenticate_with

simpleなやつ。こんな感じで使います。簡単で良いですね。

class PostsController < ApplicationController
  http_basic_authenticate_with name: "dhh", password: "secret", except: :index

内部的には、before_actionで http_basic_authenticate_or_request_with を呼ぶ実装になっている。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L76
def http_basic_authenticate_with(name:, password:, realm: nil, **options)
  raise ArgumentError, "Expected name: to be a String, got #{name.class}" unless name.is_a?(String)
  raise ArgumentError, "Expected password: to be a String, got #{password.class}" unless password.is_a?(String)
  before_action(options) { http_basic_authenticate_or_request_with name: name, password: password, realm: realm }
end

そして、http_basic_authenticate_or_request_with では、ActiveSupport::SecurityUtils.secure_compare を利用している。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L83
def http_basic_authenticate_or_request_with(name:, password:, realm: nil, message: nil)
  authenticate_or_request_with_http_basic(realm, message) do |given_name, given_password|
  # This comparison uses & so that it doesn't short circuit and
  # uses `secure_compare` so that length information isn't leaked.
  ActiveSupport::SecurityUtils.secure_compare(given_name.to_s, name) &
    ActiveSupport::SecurityUtils.secure_compare(given_password.to_s, password)
  end
end

やり方2.http_basic_authenticate_or_request_with

独自の処理を噛ませたい場合は、当然ながら「1」で呼んでいる http_basic_authenticate_or_request_with を利用することで実現できる。

class ApplicationController < ActionController::Base
  before_action :set_account, :authenticate

  private
    def authenticate
      # do something
     http_basic_authenticate_or_request_with(name: "dhh", password:  "secret")
    end

やり方3. authenticate_or_request_with_http_basic

また、当然ながら2で呼んでる authenticate_or_request_with_http_basic を呼ぶ実装でも良い。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L92
def authenticate_or_request_with_http_basic(realm = nil, message = nil, &login_procedure)
  authenticate_with_http_basic(&login_procedure) || request_http_basic_authentication(realm || "Application", message)
end

def authenticate_with_http_basic(&login_procedure)
  HttpAuthentication::Basic.authenticate(request, &login_procedure)
end

やり方4. authenticate_with_http_basic

またまた当然ながら3で呼んでる authenticate_with_http_basic を呼ぶ実装でも良い。 この場合は、基本認証を要求するレスポンスが自動で返らないので、必要に応じて request_http_basic_authentication (後述)を呼び出したりする必要がある。

Advanced Basic exampleとしてRailsのドキュメントにはこの例が記載されている。

class ApplicationController < ActionController::Base
  before_action :set_account, :authenticate

  private
    def set_account
      @account = Account.find_by(url_name: request.subdomains.first)
    end

    def authenticate
      case request.format
      when Mime[:xml], Mime[:atom]
        if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) }
          @current_user = user
        else
          request_http_basic_authentication
        end
      else
        if session_authenticated?
          @current_user = @account.users.find(session[:authenticated][:user_id])
        else
          redirect_to(login_url) and return false
        end
      end
    end
end

request_http_basic_authentication

基本認証を要求するレスポンスを返す処理。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
def request_http_basic_authentication(realm = "Application", message = nil)
  HttpAuthentication::Basic.authentication_request(self, realm, message)
end
# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
def authentication_request(controller, realm, message)
  message ||= "HTTP Basic: Access denied.\n"
  controller.headers["WWW-Authenticate"] = %(Basic realm="#{realm.tr('"', "")}")
  controller.status = 401
  controller.response_body = message
end

まとめ

色々あってややこしいので軽くまとめておく。

No. メソッド名 secure_compareの利用の有無 認証不可時に基本認証を要求するレスポンスを返すかどうか 備考
1 http_basic_authenticate_with 返す                               単に基本認証するだけならこちらがおすすめ。
2 http_basic_authenticate_or_request_with 返す 独自の処理を追加したいのであれば、こちらがおすすめ。認証不可の場合は、 基本認証を要求するレスポンスを返す。
3 authenticate_or_request_with_http_basic 返す secure_compare が不要なケース。
4 authenticate_with_http_basic 返さない ブラウザからの入力を期待しないケース。もしくは、別途任意のタイミングでrequest_http_basic_authenticationを呼んで入力させたい場合。