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:
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:
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.