A CRM detail page can quickly become crowded when everything is displayed in one place. A Lead, for example, may have its basic information, activities, related records, notes, attachments, and history. Showing all of these sections together can make the page difficult to use.
A better approach is to split the information into tabs and load each section only when it is needed.
Horilla CRM provides a simple way to do this with HorillaTabView. It is a generic base view that creates a tab bar where each tab has its own view and URL. The content is loaded using HTMX when the user opens a tab, instead of loading everything at once.
The implementation is available in horilla/contrib/generics/views/core.py, where HorillaTabView extends Django’s TemplateView.
In this article, we’ll start with a simple tab configuration and then look at how Horilla uses the same approach for detail pages, permissions, active-tab persistence, and extending existing tab views from another app.
What Does HorillaTabView Do?
At its core, HorillaTabView is quite simple.
It renders the tab_view.html template, which contains the tab buttons and the content containers for those tabs. When a user clicks a tab, HTMX sends a request to the URL configured for that tab and places the returned content into the corresponding container.
A tab button looks like this:
<button
id="tab-{{ tab.id }}"
hx-get="{{ tab.url }}?{{request.GET.urlencode}}"
hx-target="#inner-tab-{{ tab.id }}-content"
onclick="saveActiveTab('tab-{{ tab.id }}-content', '{{ request.path }}')"
>
{{ tab.title }}
</button>
The important part here is hx-get. The tab does not contain the actual page content. Instead, it points to another view that knows how to render that particular section.
When the tab is opened, HTMX requests the URL and inserts the response into the matching content container.
The tab visibility itself is handled on the client side using the Flowbite tabs functionality. Horilla also avoids loading every tab immediately. A tab is loaded when it is first opened, while the initial tab—or the user’s previously selected tab—is loaded automatically.
This keeps the main page lighter and avoids doing unnecessary work for tabs the user may never open.
In other words, a Horilla tab is essentially a small configuration describing what the tab is called and where its content comes from. The actual content remains in its own view.
Defining the Tabs
A tab is represented by a simple Python dictionary.
The template uses three main values:
- id — identifies the tab and its content container.
- url — the URL that is requested when the tab is opened.
- title — the text displayed on the tab.
For example, a simple tab view can be defined like this:
class ImportTabView(LoginRequiredMixin, HorillaTabView):
view_id = "import-data-view"
background_class = "bg-primary-100 rounded-md"
tabs = [
{
"title": _("Import Data"),
"url": reverse_lazy("core:import_data"),
"target": "import-view-content",
"id": "import-view",
},
{
"title": _("Import History"),
"url": reverse_lazy("core:import_history_view"),
"target": "import-history-view-content",
"id": "import-history-view",
},
]
The target value appears in several tab definitions in the codebase, but it is worth noting that tab_view.html does not use it directly. The actual template logic should always be treated as the source of truth when deciding which values are required.
Understanding the Configuration
HorillaTabView intentionally keeps its configuration small.
template_name defaults to tab_view.html, which provides the tab bar and content containers.
tabs defaults to an empty list and can be defined directly on the class, created inside setup(), or returned dynamically from get_tabs().
view_id provides the DOM ID for the tab view container.
background_class and background_color can be used to control the appearance of the tab bar, while tab_class controls the classes used around the tab content area.
There is another important detail: HorillaTabView is intended to be used as an HTMX fragment. It is decorated with htmx_required, so accessing it directly without an HTMX request results in a 405 response.
This makes sense because the tab view is not supposed to be a standalone page. It is a component loaded into another page.
Creating Tabs Dynamically
A static tabs list works well when the tabs never change. In real applications, however, the available tabs often depend on the current request or the user’s permissions.
Horilla supports this in two common ways.
Using setup()
If the tab configuration needs to be created for each request, it can be assigned inside setup():
class CallSettingsTabView(LoginRequiredMixin, HorillaTabView):
view_id = "call-settings-tab"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.tabs = [
{
"title": _("Access Control"),
"url": reverse_lazy("calls:settings_access_control"),
"id": "access-control",
},
{
"title": _("Providers"),
"url": reverse_lazy("calls:settings_providers"),
"id": "providers",
},
]
This is useful when the tab configuration needs information that is available during request setup.
Using get_tabs()
For permission-based tabs, get_tabs() is usually more convenient.
For example:
class CompanyInformationTabView(LoginRequiredMixin, HorillaTabView):
view_id = "company-information-view"
background_class = "bg-primary-100 rounded-md"
def get_tabs(self):
tabs = []
if self.request.user.has_perm("core.view_company"):
tabs.append(
{
"title": _("Details"),
"url": reverse_lazy("core:company_details_tab"),
"id": "company-information-view",
}
)
if self.request.user.has_perm("core.view_fiscalyear"):
tabs.append(
{
"title": _("Fiscal Year"),
"url": reverse_lazy("core:company_fiscal_year_tab"),
"id": "fiscal-year-view",
}
)
return tabs
There is no special permission property that HorillaTabView checks inside a tab dictionary.
Instead, the tab is simply added to the list when the user has permission to access it.
This keeps the tab configuration straightforward and makes permission handling explicit.
Remembering the User’s Active Tab
There is another small feature that makes tabbed pages feel much better to use: Horilla remembers the last tab a user opened.
Without this, refreshing a detail page would always take the user back to the first tab. If someone regularly works from the Activity or History tab, having to open it again every time becomes frustrating.
When a user clicks a tab, Horilla calls saveActiveTab():
function saveActiveTab(tabTarget, path) {
clearTimeout(_saveActiveTabTimer);
_saveActiveTabTimer = setTimeout(function() {
$.ajax({
url: '/active-tab/',
type: 'POST',
data: {
tab_target: tabTarget,
path: path,
csrfmiddlewaretoken: '{{ csrf_token }}'
},
});
}, 500);
}
The request is handled by SaveActiveTabView, which stores the selected tab in an ActiveTab record:
ActiveTab.objects.update_or_create(
created_by=user,
path=path,
company=company if company else user.company,
defaults={"tab_target": tab_target},
)
When the tab view is loaded again, HorillaTabView checks whether the user already has a saved tab for that page:
active_tab = ActiveTab.objects.filter(
created_by=self.request.user,
path=self.request.path
).first()
if active_tab:
context["active_target"] = active_tab.tab_target
The template then uses this value to select the correct tab and automatically load its content.
So if a user was last working in the History tab, returning to the same page can take them directly back there.
Adding Tabs to a Detail Page
HorillaTabView is not normally used as an independent page. It is commonly placed inside a HorillaDetailView.
The detail view provides a tab_url, and detail_view.html loads the tab view using HTMX:
{% if tab_url %}
<div id="tab-session">
<div
hx-get="{{ tab_url }}?{{ request.GET.urlencode}}&object_id={{obj.id}}..."
hx-trigger="load"
></div>
</div>
{% endif %}
This creates a useful separation between the page itself and its tabs.
The detail page is responsible for the overall structure, while the tab view takes care of the tab navigation and individual tab content.
For record detail pages, Horilla provides another class called HorillaDetailTabView.
It builds on HorillaTabView and makes common record tabs easier to configure. These can include:
- Details
- Activity
- Related Lists
- Notes & Attachments
- History
For example, the Leads detail page can configure its tabs like this:
class LeadsDetailViewTabView(LoginRequiredMixin, HorillaDetailTabView):
"""Lead Detail Tab View"""
def _prepare_detail_tabs(self):
self.object_id = self.request.GET.get("object_id")
self.model = Lead
if self.object_id:
obj = Lead.objects.get(pk=self.object_id)
if obj.is_convert:
self.urls = {
"details": "leads:leads_details_tab",
"history": "leads:leads_history_tab_view",
}
else:
self.urls = {
"details": "leads:leads_details_tab",
"activity": "leads:lead_activity_detail_view",
"related_lists": "leads:lead_related_lists",
"notes_attachments": "leads:leads_notes_attachments",
"history": "leads:leads_history_tab_view",
}
super()._prepare_detail_tabs()
Here, the tab configuration changes depending on the state of the Lead.
A converted Lead only gets the Details and History tabs, while an active Lead gets the complete set.
HorillaDetailTabView takes the configured urls dictionary and converts it into the tab definitions needed by HorillaTabView. It also handles the object ID when generating the URLs.
The Notes & Attachments tab can additionally be restricted using the record-access checks, so a user does not see the tab unless they are allowed to access that record.
Extending an Existing Tab View
Sometimes a separate application needs to add a tab to a page that already exists.
For example, imagine a compliance application that wants to add an Audit tab to the existing Company Information page.
Instead of modifying the original view, Horilla provides TabExtension and _inherit_tab.
An extension can look like this:
class ComplianceTabExtension(TabExtension):
_inherit_tab = "horilla.contrib.core.views.branches.CompanyInformationTabView"
def get_tabs(self):
tabs = list(CompanyInformationTabView.get_tabs(self))
tabs.append(
{
"title": _("Audit"),
"url": reverse_lazy("compliance:audit_tab"),
"id": "audit",
}
)
return tabs
The idea is similar to the inheritance mechanism used in Odoo.
When the extension is registered, Horilla keeps it in a global registry. Once the Django applications are ready, Horilla creates a composed class for the target tab view.
Conceptually, the resulting class looks like:
Composed
-> Extension
-> OriginalTabView
Because HorillaTabView.as_view() resolves the composed class when the request is handled, the existing URL can continue pointing to the original view while the extended version adds the new tab.
There is one important detail to remember when writing a TabExtension.
Because of the way the composed class’s method resolution order is built, calling:
super().get_tabs()
from the extension is not the correct approach.
Instead, call the original view’s method directly:
CompanyInformationTabView.get_tabs(self)
Then add the new tab to the returned list.
HorillaTabView provides a simple way to build tabbed interfaces without creating custom HTMX wiring for every page.
The basic idea is straightforward: define a list of tabs, give each one a URL, and let the base view handle the tab navigation and lazy loading.
For simple pages, the tabs class attribute may be enough. When tabs depend on the request or permissions, setup() and get_tabs() provide more flexibility.
For record detail pages, HorillaDetailTabView takes the idea further by turning a urls dictionary into the standard Details, Activity, Related Lists, Notes & Attachments, and History tabs.
The active-tab functionality also means users can return to a page without losing the section they were working on. And when another application needs to add its own tab, TabExtension and _inherit_tab make it possible without modifying the original view.
Once the relationship between tabs, get_tabs(), and HorillaDetailTabView is clear, building a new tabbed page—or extending an existing one—becomes a fairly small and predictable change.