Nesting content with Tab Navigation

Nesting content with Tab Navigation

Part 24 of building a Rails 7 application

This application has a number of models that have has_many associations to other related models. An example being an ItemSellPack has many aliases declared through ItemSellPackAlias. Presenting these related records is a user interface decision of which there are a couple different options.

The first option would be to modify the existing "Index" page for ItemSellPack so each row is expandable. This could reveal the additional related information for that row and it would mean the user does not leave that screen. My main concern with that approach is that it possibly makes the screen too busy, and finding the anchor to collapse the section can sometimes be a pain.

Another option is to open a new modal window within the "Index" page which would contain the related information. This I like a bit better, although it would necessitate adding resizable modals in order to fit as much content as possible, which then leads to questioning why it is a modal at all and not another separate page.

The option I have gone with though is modifying the "Show" and "Edit" pages so they have a series of Tabs that can be used to navigate between the related data for the selected record.

Here is the Storybook view of what this component would look like:

image.png

So to make this work I need a way to "nest" records so I only return those. I need a ViewComponent for Tab Navigation. The "count" badges need to be updated in real time so I need a solution for that as well.

Here is what the end result will look like:

Starting off with making records nestable, the first thing I'll do is modify the routes. I specify that the item_sell_pack_aliases can be nested under item_sell_packs, this will allow the controller to return just those aliases associated with the specified pack. I'm making the routes shallow so it does not draw any unnecessary routes. The "new" action I will no longer respond to as I'm handling creating new records using my own embedded modal.

config/routes.rb

  resources :item_sell_pack_aliases, except: %i[new]
  resources :item_sell_packs, shallow: true, except: %i[new] do
    resources :item_sell_pack_aliases, only: %i[index create]
  end

On the controller side I've added a Nested concern which will provide a number of useful methods and helpers for any controller that needs to return records scoped by a parent.

app/controllers/concerns/nested.rb

module Nested
  extend ActiveSupport::Concern

  included do
    helper_method :nested?, :filter_url
  end

  private

  def build_resource
    super.tap do |r|
      r.send("#{parent_association_name}=", parent)
    end
  end

  def filter_url
    nil
  end

  def default_filters
    return {} unless nested?

    { "#{parent_foreign_key}_eq": parent.id }
  end

  def nested?
    parent.present?
  end

  def parent_class
    raise(NotImplementedError)
  end

  def parent
    raise(NotImplementedError)
  end

  def parent_association
    resource_class
      .reflect_on_all_associations(:belongs_to)
      .find { |a| a.name == parent_class.resource_name.to_sym }
  end

  def parent_foreign_key
    parent_association.foreign_key
  end

  def parent_association_name
    parent_association.name
  end
end

There are two methods that need to be specifically implemented for the controller (parent_class and parent), this lets anything that is rendering the nested index know who the parent record is and what class it is. And there are three other methods (collection_path_method, filter_url, default_filters) that will be used by the CollectionPager component and the Filter component. I'll get to the reason for why in a moment.

app/controllers/item_sell_pack_aliases_controller.rb

  def collection_path_method
    return super unless nested?

    :item_sell_pack_item_sell_pack_aliases_path
  end

  def filter_url
    return unless nested?

    @filter_url ||= public_send(collection_path_method, item_sell_pack_id: @parent)
  end

  def default_filters
    super.merge({ confirmed_true: '1', confirmed_not_true: '0' })
  end

  def parent_class
    @parent_class ||= ItemSellPack
  end

  def parent
    @parent ||=
      parent_class
      .find_by(id: params[:item_sell_pack_id] || params.dig(:item_sell_pack_alias, :item_sell_pack_id))
  end

I'm also adding a Parent concern to the models that will have parents (and therefore be nestable).

app/models/concerns/parent.rb

module Parent
  extend ActiveSupport::Concern

  def parent
    raise(NotImplementedError)
  end

  def association_with_class(klass:, macro: :has_many)
    self.class.reflect_on_all_associations(macro).select { |assoc| assoc.klass == klass }
  end

  def klass_association_name(klass:)
    association_with_class(klass: klass).first.name
  end
end

Again, there is one method that needs an implementation in the model that includes this concern. This allows for a nested record to return whatever association is deemed to be the "parent" for that record. Currently only one association can be the parent, I'm not sure if multiple parents will be required in the future.

app/models/item_sell_pack_alias.rb

  def parent
    item_sell_pack
  end

Defining the Tab Navigation component is straightforward. It has an id and renders as many tab components as have been provided.

app/components/tab_navigation/component.rb

module TabNavigation
  class Component < ViewComponent::Base
    renders_many :tabs, Tab::Component

    attr_accessor :id

    def initialize(id:)
      super
      @id = id
    end
  end
end

Tab components are a bit more complex. It will need to take care of displaying the badge if a count is provided and highlighting which tab is active. The URL the user will be taken to when clicking a tab must also be provided.

app/components/tab/component.rb

module Tab
  class Component < ViewComponent::Base
    include Turbo::StreamsHelper
    include IconsHelper

    attr_accessor :id, :label, :url, :active, :options

    def initialize(id:, label:, url:, active: false, options: {})
      super
      @id = id
      @label = label
      @url = url
      @active = active
      @options = options
    end

    def tab_id
      return id unless parent

      "#{parent.resource_name}_#{parent.id}_#{id}"
    end

    def tab_classes
      active ? 'text-gray-900 bg-sky-100' : 'text-gray-500 hover:text-gray-700'
    end

    def badge_classes
      active ? 'bg-gray-100 text-gray-900' : 'bg-sky-100 text-sky-600'
    end

    def icon_options
      options[:icon_options]
    end

    def parent
      options[:parent]
    end

    def badge_count
      options[:badge_count]
    end
  end
end

app/components/tab/component.html.erb

<%= turbo_stream_from("#{tab_id}_count") if parent %>
<a
  id="tab_<%= tab_id %>"
  href="<%= url %>"
  class="<%= tab_classes %> group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10 inline-flex items-center"
>
  <span id="tab_<%= tab_id %>--icon"><%= icon(**icon_options) if icon_options %></span>
  <span id="tab_<%= tab_id %>--label"><%= label %></span>
  <% if badge_count.present? %>
    <!-- Current: "bg-sky-100 text-sky-600", Default: "bg-gray-100 text-gray-900" -->
    <span
      id="tab_<%= tab_id %>--badge_count"
      class="<%= badge_classes %> hidden ml-3 py-0.5 px-2.5 rounded-full text-xs font-medium md:inline-block"
    >
      <p id="tab_<%= tab_id %>--badge_count__integer"><%= badge_count %></p>
    </span>
  <% end %>
  <span aria-hidden="true" class="bg-transparent absolute inset-x-0 bottom-0 h-0.5"></span>
</a>

Here is an example of this Tab Navigation being used. Note also the call to nested?, this was one of the new helper methods introduced. These tabs should not be displayed if viewing the full list of Item Sell Pack Aliases, it is only applicable when being nested under a selected Item Sell Pack.

app/views/item_sell_pack_aliases/index.html.erb

  <% if nested? %>
    <%= render TabNavigation::Component.new(id: dom_id(@parent)) do |component| %>
      <% component.with_tab(
             id: :details,
             label: 'Details',
             url: edit_item_sell_pack_path(@parent),
             active: current_page?(controller: :item_sell_packs),
             options: { parent: @parent, icon_options: { name: :library, colour: :gray } }
         ) %>
      <% component.with_tab(
             id: :item_sell_pack_aliases,
             label: 'Aliases',
             url: '#',
             active: current_page?(controller: :item_sell_pack_aliases),
             options: {
                 parent: @parent,
                 icon_options: { name: :folder_open, colour: :gray },
                 badge_count: @parent.item_sell_pack_aliases.size
             }
         ) %>
    <% end %>
  <% end %>

So this is what it looks like now:

image.png

There are some issues that need to be addressed though around filtering and paging. Up until now these features have assumed that the index controller only filters based on whatever criteria the user has entered, a search term for example. Once an index is nested under another record though the records returned should always be scoped to the parent record's collection.

For filtering a default filter is applied that specifies that the returned records must also match the id of the parent using the relationship that was deemed to be the "parent". That's these methods:

  def default_filters
    return {} unless nested?

    { "#{parent_foreign_key}_eq": parent.id }
  end
  def default_filters
    super.merge({ confirmed_true: '1', confirmed_not_true: '0' })
  end

And the pager needs to call a different URL if the the records are nested. That is this method:

  def collection_path_method
    return super unless nested?

    :item_sell_pack_item_sell_pack_aliases_path
  end

and in the CollectionPager component it can now receive a parameter for the parent record so it can produce the correct URL.

app/components/collection_pager/component.rb

module CollectionPager
  class Component < ViewComponent::Base
    attr_reader :paginator, :collection_path_method, :parent_param, :filter_params

    def initialize(paginator:, collection_path_method:, parent_param: nil, filter_params: nil)
      super
      @paginator = paginator
      @collection_path_method = collection_path_method
      @filter_params = filter_params
      @parent_param = parent_param
    end

    def pager_url
      public_send(collection_path_method, parent_param, page: paginator.next, q: filter_params&.to_unsafe_h)
    end
  end
end

These changes now mean that filtering and paging are working correctly. There is one last thing to do which is dynamically updating the badge count on a tab if the number of records changes through creation or deletion. The Tab Component is already displaying the count if given it, but this value is static. To make it update without a page reload I can use Turbo.

A stream has already been set up on the Tab Component. It looked like this:

<%= turbo_stream_from("#{tab_id}_count") if parent %>

This effectively generates a stream with a name that will look similar to item_sell_pack_55_item_sell_pack_aliases_count. So now I just need to generate a turbo stream that will replace the badge with a new number.

I've introduced a new broadcast related concern to do just that. This concern is only included in classes that will be nested.

app/models/concerns/nested_broadcast.rb

module NestedBroadcast
  extend ActiveSupport::Concern

  included do
    after_create_commit lambda {
      broadcast_nested_resource_creation
      broadcast_nested_resource_count
    }

    after_destroy_commit lambda {
      broadcast_nested_resource_deletion
      broadcast_nested_resource_count
    }

    after_update_commit lambda {
      broadcast_nested_resource_update
    }
  end

  private

  def broadcast_nested_resource_creation
    broadcast_prepend_later_to(
      broadcast_nested_collection_channel,
      partial: "#{resource_name_plural}/row",
      locals: { resource: self },
      target: 'collection_rows'
    )
  end

  def broadcast_nested_resource_deletion
    broadcast_remove_to(
      broadcast_nested_collection_channel,
      target: "turbo_stream_#{resource_name}_#{id}"
    )
  end

  def broadcast_nested_resource_update
    broadcast_replace_later_to(
      broadcast_nested_collection_channel,
      partial: "#{resource_name_plural}/row",
      locals: { resource: self },
      target: "turbo_stream_#{resource_name}_#{id}"
    )
  end

  def broadcast_nested_resource_count
    # Not using _later_ here to avoid an ActiveJob::DeserializationError when records are deleted
    broadcast_replace_to(
      "#{nested_resource_collection_id}_count",
      partial: 'badge_count',
      locals: {
        tab_id: nested_resource_collection_id,
        badge_count: parent.public_send(parent.klass_association_name(klass: self.class)).size
      },
      target: "tab_#{nested_resource_collection_id}--badge_count__integer"
    )
  end

  def broadcast_nested_collection_channel
    "#{parent.resource_name}_#{parent.id}_#{resource_name_plural}"
  end

  def nested_resource_collection_id
    "#{parent.resource_name}_#{parent.id}_#{parent.klass_association_name(klass: self.class)}"
  end
end

This concern will broadcast a badge_count partial every time a create or delete occurs.

app/views/application/_badge_count.html.erb

<p id="tab_<%= id %>--badge_count__integer"><%= badge_count %></p>

Another issue this concern fixes is the displaying and deleting of records from the "Index" pages too when they are nested. Previously the only channel that was receiving these was the pluralization of the resource name. If a record was created while a user was looking at the nested controller it should only show records belonging to the nested parent. To achieve that a different stream channel is specified when viewing the nested "Index", and the nested broadcast will send only to that channel.

app/views/item_sell_pack_aliases/index.html.erb

<% if nested? %>
  <%= turbo_stream_from "#{@parent.resource_name}_#{@parent.id}_item_sell_pack_aliases" %>
<% else %>
  <%= turbo_stream_from "item_sell_pack_aliases" %>
<% end %>

Now with all of that in place I have the results from the video linked above. There are multiple tabs for navigating between different related sections of a specific resource. Filtering and pagination of the nested records works as expected. And when a record is added or deleted the appropriate badge count is incremented or decremented automatically.