In a CRM, users may want to view the same records in different ways. A lead list might be useful in a table, while a kanban board is better for tracking stages. A card view, chart, or timeline can also be useful depending on what the user is working on.

Handling all these layouts separately for every module can quickly become repetitive. You would need a page to load the navbar, another view for each layout, and some logic to decide which layout should be displayed.

Horilla CRM handles this with HorillaView.

HorillaView works as a page shell. It does not render the actual records itself. Instead, it points to the views that handle each layout and decides which one should be loaded.

The implementation is in horilla/contrib/generics/views/core.py, and it extends Django’s TemplateView.

1. What Does HorillaView Do?

HorillaView is mainly responsible for providing the structure of the page.

Its default template contains two main containers:

<div id="navBar" hx-get="{{ nav_url }}?{{ request.GET.urlencode }}" hx-trigger="load"></div>
<div id="mainSession"
     hx-get="{{ layout_url }}?{{ query }}{% if filter_form %}&hx_trigger={{filter_form}}{% endif %}"
     hx-trigger="load">
</div>

When the page loads, HTMX makes two requests.

The first request loads the navigation bar into #navBar. This is normally a HorillaNavView subclass containing things such as search, filters, and layout buttons.

The second request loads the currently selected layout into #mainSession. That could be a list, kanban board, card view, chart, or any other supported layout.

So HorillaView does not need to know how a table or kanban board works. Those parts are handled by their own generic views such as HorillaListView, HorillaKanbanView, and HorillaCardView.

This is why a HorillaView is normally used together with a HorillaNavView.

2. Creating a Simple HorillaView

Not every page needs multiple layouts. For example, a settings page might only need a list.

In that case, the view can be very small:

from django.contrib.auth.mixins import LoginRequiredMixin
from horilla.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaView




class CustomFieldView(LoginRequiredMixin, HorillaView):
    """Shell view for the custom fields settings page."""


    template_name = "settings/settings_list_shell.html"
    view_id = "custom-fields-view"
    nav_url = reverse_lazy("custom_fields:navbar")
    list_url = reverse_lazy("custom_fields:list")

Here, nav_url points to the navbar and list_url points to the list view.

There is no need to configure kanban, card, chart, or other layouts because this page does not use them.

HorillaView also checks its configuration. A nav_url must be provided, and at least one layout URL or a custom_layouts entry must exist. If the configuration is missing, it raises ImproperlyConfigured instead of allowing the page to fail later with an empty result.

3. HorillaView Configuration

HorillaView keeps the configuration fairly simple. The main attributes are:

VariableDefaultPurpose
template_name“base.html”Template used for the page shell.
nav_url“”URL of the navbar view.
list_url“”URL for the list layout.
kanban_url“”URL for the kanban layout.
group_by_url“”URL for the group-by layout.
card_url“”URL for the card layout.
split_view_url“”URL for the split-view layout.
chart_url“”URL for the chart layout.
timeline_url“”URL for the timeline layout.
view_id“”Optional DOM identifier.
custom_layouts{}Additional layouts provided by the application.
default_layoutNot setLayout to use when no layout parameter is provided.

Only nav_url and at least one layout need to be configured.

4. Creating a Page With Multiple Layouts

For a CRM module such as Leads, several layouts may be useful.

A HorillaView for Leads can define all the built-in layouts like this:

from django.contrib.auth.mixins import LoginRequiredMixin
from horilla.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaView




class LeadView(LoginRequiredMixin, HorillaView):
    """
    Render the lead page.
    """


    nav_url = reverse_lazy("leads:leads_nav")
    list_url = reverse_lazy("leads:leads_list")
    kanban_url = reverse_lazy("leads:leads_kanban")
    group_by_url = reverse_lazy("leads:leads_group_by")
    card_url = reverse_lazy("leads:leads_card")
    split_view_url = reverse_lazy("leads:leads_split_view")
    chart_url = reverse_lazy("leads:leads_chart")
    timeline_url = reverse_lazy("leads:leads_timeline")

Each URL points to another view responsible for rendering that particular layout.

For example:

  • leads_list points to a HorillaListView
  • leads_kanban points to a HorillaKanbanView
  • leads_card points to a HorillaCardView
  • leads_chart points to the chart view

HorillaView does not need to know what those views actually render. It only needs to know where they are.

5. How Does HorillaView Choose the Layout?

The layout is selected using the layout query parameter.

For example:

?layout=kanban

When a request comes in, get_layout_url() checks the requested layout and matches it with the URLs configured on the view.

The basic flow is:

  1. Check the layout query parameter.
  2. Build the available layout mapping.
  3. Check whether the requested layout has a configured URL.
  4. If no layout was requested, use default_layout when one is defined.
  5. If there is still no match, fall back to list_url.
  6. If list_url is not available, kanban_url is used as the fallback.

This keeps the layout switching simple. The page shell stays the same, and only the content loaded into #mainSession changes.

The navbar uses the same layout configuration to show the available layout buttons, so the selected layout and the displayed content stay in sync.

Restricting Layouts for a Specific Case

A module can also add its own rules.

For example, converted leads may not support some of the normal Lead layouts. LeadView can handle that by overriding dispatch():

def dispatch(self, request, *args, **kwargs):
    view_type = request.GET.get("view_type")


    if view_type == "converted_lead" and request.GET.get("layout") in (
        "kanban",
        "group_by",
        "card",
        "split_view",
        "chart",
    ):
        get_params = request.GET.copy()
        get_params.pop("layout", None)


        query_string = get_params.urlencode()
        redirect_url = request.path


        if query_string:
            redirect_url += f"?{query_string}"


        return RedirectResponse(
            request=request,
            redirect_to=redirect_url,
        )


    return super().dispatch(request, *args, **kwargs)

In this case, if someone tries to open an unsupported layout for converted leads, the request is redirected back to the default layout.

6. Adding a Custom Layout

The built-in layouts are enough for most CRM pages, but sometimes a module needs something different.

For example, a Roles page might need a hierarchy view.

This can be added through custom_layouts:

class RolesView(LoginRequiredMixin, HorillaView):
    """
    Template view for team role page
    """


    template_name = "settings/settings_list_shell.html"
    view_id = "role-view"
    default_layout = "hierarchy"


    nav_url = reverse_lazy("core:roles_nav_bar")
    list_url = reverse_lazy("core:role_list_view")


    custom_layouts = {
        "hierarchy": {
            "url": reverse_lazy("core:roles_hierarchy_view"),
            "icon": "assets/icons/hierarchy.svg",
            "label": _("Hierarchy"),
        },
    }

The key, hierarchy, is the value that can be passed through the URL:

?layout=hierarchy

The custom layout also provides its URL, icon, and label. The paired HorillaNavView can use this information when creating the layout button.

Here, default_layout is also set to hierarchy, so the hierarchy view opens by default when no layout is specified.

7. Using a Different Shell Template

The default shell template is base.html, but a page can use another template if it needs a different structure.

For example, settings pages can use:

template_name = “settings/settings_list_shell.html”

The important part is that the template still provides the required navBar and mainSession containers.

The layout resolution logic does not change. Only the outer page structure is different.

8. Pairing HorillaView With HorillaNavView

A complete multi-layout page normally has both a HorillaView and a HorillaNavView.

For example:

from django.contrib.auth.mixins import LoginRequiredMixin
from horilla.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaNavView, HorillaView

class OpportunityView(LoginRequiredMixin, HorillaView):
    """Render the opportunity page."""


    nav_url = reverse_lazy("opportunities:opportunities_nav")
    list_url = reverse_lazy("opportunities:opportunities_list")
    kanban_url = reverse_lazy("opportunities:opportunities_kanban")
    group_by_url = reverse_lazy("opportunities:opportunities_group_by")
    card_url = reverse_lazy("opportunities:opportunities_card")
    split_view_url = reverse_lazy("opportunities:opportunities_split_view")
    chart_url = reverse_lazy("opportunities:opportunities_chart")
    timeline_url = reverse_lazy("opportunities:opportunities_timeline")




class OpportunityNavbar(LoginRequiredMixin, HorillaNavView):
    """Opportunity Navbar"""


    search_url = reverse_lazy("opportunities:opportunities_list")
    main_url = reverse_lazy("opportunities:opportunities_view")
    kanban_url = reverse_lazy("opportunities:opportunities_kanban")
    group_by_url = reverse_lazy("opportunities:opportunities_group_by")
    card_url = reverse_lazy("opportunities:opportunities_card")
    split_view_url = reverse_lazy("opportunities:opportunities_split_view")
    chart_url = reverse_lazy("opportunities:opportunities_chart")
    timeline_url = reverse_lazy("opportunities:opportunities_timeline")

You may notice that many of the URLs are defined in both classes.

That is expected.

HorillaView needs those URLs to decide which view should be loaded into #mainSession. HorillaNavView needs them to build the layout buttons and determine the active layout.

Keeping the two configurations aligned is important. If a layout is added to one side but not the other, the page can end up with a layout that is available in one place but missing from the other.

HorillaView is mainly a composition layer. It does not try to render lists, kanban boards, charts, or timelines itself. Instead, it provides the page shell and decides which existing view should be loaded based on the requested layout.

Once the individual layout views are already available, setting up the main page becomes straightforward: connect them to HorillaView, connect the same layouts to the HorillaNavView, and let HTMX handle loading the selected view into the page.

Share this article