Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 特定ユーザー限定のコンテンツをTurbo Streamで送信する場合の注意事項(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: 特定ユーザー限定のコンテンツをTurbo Streamで送信する場合の注意事項(翻訳)

特定ユーザーを対象としてパーシャルに送信したコンテンツを、条件に応じて表示・非表示するにはどうすればよいでしょうか?
カレントユーザーが作成したメッセージにだけ「Edit」ボタンを表示する、あるいは管理ユーザーにだけ管理用のコントロールを表示する、といったシナリオが考えられます。

これは、特にTurbo Stream経由で行う場合は注意が必要です。さもないと、必要な権限を持たないユーザーにも誤って同じパーシャルが表示されてしまうかもしれません。

そこで「カレントユーザーが作成したメッセージにだけEditアクションやDeleteアクションを表示する」という、よくあるシナリオを考えてみましょう。

これは、ちょうど私のRails Designersで取り組まなければならなかったケースでした(その成果はforge.railsdesigners.comに盛り込まれています)。

まず、ビューで以下のようにRailsの標準的なif条件文で処理しているとします。

<ul>
  <% @messages.each do |message| %>
    <li>
      <small>From <%= message.author.name %> (<%= message.author.id %>)</small>

      <p>
        <%= message.content %>
      </p>

      <% if Current.user.id == message.author.id %>
        <ul>
          <li><%= link_to "Edit Message", "#" %></li>
          <li><%= link_to "Delete Message", "#" %></li>
        </ul>
      <% end %>

      <hr>
    </li>
  <% end %>
</ul>

このコードは通常のページ読み込みでは期待通りに機能しますが、Turbo Streamでは期待通りに機能しません

どういうことかというと、ブロードキャストで多数のユーザーに更新をかけたときに、サーバー側の条件ロジックは「ブロードキャスト中にアクティブだったユーザーのコンテキスト」に基づいて実行されます。つまり、ブロードキャストを受信するユーザーのコンテキストに必ずしも沿っていない可能性があるのです。

🔗 turbo-show要素を導入する

完全なコードについては以下のリポジトリを参照してください。
rails-designer-repos/turbo-show - GitHub

(GItHubリポジトリに置いたアプリの振る舞い: "current user"だけが自分のメッセージを編集・削除できます)

このロジックをサーバー側で処理する代わりに、HTMLのカスタム要素を使ってクライアントに条件付きレンダリングをプッシュできます。
カスタムのturbo-show要素を使えば、以下のように問題を解決できます。

<ul>
  <% @messages.each do |message| %>
    <li>
      <small>From <%= message.author.name %> (<%= message.author.id %>)</small>

      <p>
        <%= message.content %>
      </p>

      <turbo-show when="current-user-id" is="<%= message.author.id %>">
        <ul>
          <li><%= link_to "Edit Message", "#" %></li>
          <li><%= link_to "Delete Message", "#" %></li>
        </ul>
      </turbo-show>

      <hr>
    </li>
  <% end %>
</ul>

このturbo-show要素は、カレントユーザーのid(ここではメタタグに保存されています)がメッセージのauthor idと一致しているかどうかをwhen="current-user-id" is="<%= message.author.id %>"のようにチェックします。条件が満たされない場合は、要素のコンテンツを含む要素全体がDOMから削除されます。

このHTMLを読むだけで、ここで何が起きているかがわかりますか?「Turboはこの要素を、カレントユーザーidがauthor idと一致する場合にのみ表示する」ということです 😘👌

🔗 カスタム要素の実装方法

そもそもTurboはturbo-frameturbo-streamといったカスタム要素を多用しているので、私も同じパターンを踏襲しています。

以下は、turbo-showの完全な実装です。

class TurboShow extends HTMLElement {
  static #operators = {
    is: (content, value) => content === value
    // "operators"は必要に応じて拡張する(例: "contains"、"is_not")
  }

  connectedCallback() {
    if (this.#removable()) this.remove()
  }

  // private

  #removable() {
    if (this.#whenAttributeMissing()) return true
    if (this.#metaTagMissing()) return true
    if (this.#unmetConditions()) return true

    return false
  }

  #whenAttributeMissing() {
    return !this.hasAttribute("when")
  }

  #metaTagMissing() {
    return !this.#metaTag
  }

  #unmetConditions() {
    const operators = Object.keys(TurboShow.#operators)
      .filter(operator => this.hasAttribute(operator))

    if (operators.length === 0) return false

    return operators.some(operator => {
      const value = this.getAttribute(operator)
      const check = TurboShow.#operators[operator]

      return !check(this.#metaContent, value)
    })
  }

  get #metaTag() {
    return document.querySelector(`meta[name="${this.getAttribute("when")}"]`)
  }

  get #metaContent() {
    return this.#metaTag.getAttribute("content")
  }
}

customElements.define("turbo-show", TurboShow)

かなり読みやすいコードだと思いますが、念のために説明しておくと、この要素は以下のように動作します。

  1. when属性で指定された名前を持つメタタグを探索する
  2. そのメタタグの内容を、isなどの演算子属性の値と比較する
  3. 条件が満たされない場合は自分自身をDOMから削除する

レイアウトのメタタグにも、カレントユーザーのidなどの情報を追加しておく必要があります。

<meta name="current-user-id" content="<%= current_user.id %>" />

turbo-showをRailsアプリケーションで動かすには、まずconfig/importmap.rbにライブラリへのパスを追加します。

pin_all_from "app/javascript/library", under: "library"

次に、app/javascript/application.jsでカスタムturbo-show要素をインポートします。

import "library/turbo_show"

このアプローチにすることで、Turbo Streamでスムーズに動作する条件付きレンダリングをクライアント側でクリーンに書けるようになります。パーシャルを複数のユーザーに送信したとしても、個別のユーザーのコンテキストに応じてコンテンツが適切にクライアントで表示されるようになります。ただし、もちろん実際のエンドポイントでは適切な承認(authorization)が行われていなければなりません。

#operatorsハッシュには、containsis_notやor greater_thanなどの複雑な条件も簡単に追加できるので、さまざまな条件付きレンダリングの状況に応じて柔軟に対応できます。


Railsでこの手法を活用することで、特定ユーザーに限定したコンテンツ表示方法の幅が広がります。

  • ロールベースの表示権限
    user-roleメタタグをis="admin"でチェックすれば、管理者にだけ管理用機能を表示できます。

  • フィーチャーフラグ
    when="feature-flags"contains="beta-feature"を指定することで、特定のユーザーやプランに応じて機能をオンオフできます。

  • サブスクリプションに応じた表示
    when="subscription-tier"is="pro"のように指定することで、ユーザーのサブスクリプション状況に応じた限定版コンテンツやプランのアップグレードを促すメッセージを表示できます。

  • チームメンバー限定の表示
    when="team-id"を現在のコンテキストを比較することで、チームメンバーだけが実行可能なアクションを表示できます。

  • 地域制限
    when="user-country"に適切なカスタム演算子を組み合わせれば、ユーザーの居住地域に応じてコンテンツの表示・非表示を制御できます。

  • A/Bテスト
    メタタグに保存されている実験グループに属しているかどうかに応じて、異なるUIを表示できます。


私がRails Designerから切り出したTurbo Transition↓のときと同様に、このツールをオープンソース化することも検討しましたが、何しろ私が今後公開を考えているオープンソースプロジェクトのリストは増える一方です。

参考: Introducing Turbo Transition: create smoother Turbo Streams | Rails Designer

そういうわけで、本記事のアイデアを皆さんの手で自由にnpmパッケージにしていただいて構いません。パッケージができたらリンクしますので、ぜひ私までお知らせください。

関連記事

Perron:「Railsベースの」静的サイトジェネレータ(翻訳)

Rails開発者なら知っておきたいタイポグラフィの大事な基礎知識(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。