A good list view is one of the most important parts of a CRM application. Whether you are displaying contacts, leads, opportunities, tasks, or any other type of record, users usually expect more than just a basic table. They need to search through records, apply filters, sort information, perform bulk actions, and quickly open individual records.
Building all of these features separately for every module can take a lot of time and often leads to duplicated code.
Horilla simplifies this process with its generic HorillaListView. Instead of creating the complete list-view functionality from scratch, you can extend this class and configure the features your particular module needs.
HorillaListView provides support for features such as filtering, sorting, bulk operations, infinite scrolling, saved views, column visibility, and permission-aware record handling. The base implementation is located in horilla/contrib/generics/views/list.py and is built on Django’s standard ListView, together with HorillaListViewMixin.
In this guide, we will start with a simple contact list and then gradually explore the different configuration options available in HorillaListView.
1. Creating a Basic List View
The simplest way to create a Horilla list view is to subclass HorillaListView and define the model, URLs, and columns you want to display.
For example, a basic contact list can look like this:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaListView
from .models import Contact
from .filters import ContactFilter
class ContactListView(LoginRequiredMixin, HorillaListView):
model = Contact
view_id = "contact-list"
search_url = reverse_lazy("contacts:contact_list_view")
main_url = reverse_lazy("contacts:contacts_view")
filterset_class = ContactFilter
columns = [
"first_name",
"last_name",
"title",
"email",
"contact_number",
"contact_source",
]
Even with this small amount of configuration, the list view can provide a table, pagination, filtering, and the supporting functionality provided by HorillaListView.
The main idea is simple: define what your list needs and let the generic view handle the common behavior.
2. Understanding the Configuration Options
HorillaListView exposes a number of class attributes that allow you to customize how the list behaves and looks.
Instead of using every option in every view, you can select only the settings that are relevant to your particular module.
Identity and Routing
These properties identify the list and control how it communicates with the rest of the application.
| Variable | Default | Purpose |
| model | Django default | Specifies the model used by the list view. |
| template_name | “list_view.html” | Template used to render the list. |
| context_object_name | “queryset” | Name used for the queryset in the template. |
| view_id | “” | Unique DOM identifier for the list instance. |
| main_url | “” | URL of the main/full-page list view. |
| search_url | “” | Endpoint used for searching, filtering, and sorting. |
| page_kwarg | “page” | Query parameter used for pagination. |
| paginate_by | 100 | Number of records loaded per page. |
For example:
view_id = "review-job-list"
search_url = reverse_lazy("reviews:review_job_list_view")
main_url = reverse_lazy("reviews:review_job_view")
The view_id becomes especially important when multiple list views exist on the same page because it helps keep HTMX targets and selectors scoped to the correct list.
3. Configuring Columns and Table Display
The columns attribute controls which fields appear in the table.
You can simply provide model field names:
columns = [
"first_name",
"last_name",
"email",
]
You can also provide custom labels when the default field name is not suitable:
columns = [
("Name", "first_name"),
("Email Address", "email"),
"contact_source",
]
Horilla also provides several options for controlling the table layout.
| Variable | Default | Purpose |
| columns | [] | Defines the visible columns. |
| exclude_columns | [] | Removes specific fields when columns are generated automatically. |
| header_attrs | [] | Adds HTML attributes to table headers. |
| col_attrs | [] | Adds HTML attributes to individual table cells. |
| raw_attrs | [] | Applies HTML attributes to every cell. |
| table_width | True | Controls the table width behavior. |
| table_class | True | Controls the table styling classes. |
| table_height_as_class | “” | Adds a Tailwind height class to constrain the table. |
| table_auto | False | Uses table-auto instead of table-fixed. |
| list_column_visibility | True | Enables the column visibility option. |
For example, you can give specific columns a custom width:
header_attrs = [
{"email": {"style": "width: 250px;"}},
]
This is useful when certain fields contain longer values and need additional space.
4. Adding Sorting
Sorting makes it easier for users to organize records based on a particular column.
Sorting is enabled by default, but you can control how it behaves using several attributes.
| Variable | Default | Purpose |
| enable_sorting | True | Enables or disables column sorting. |
| default_sort_field | None | Sets the default field used for ordering. |
| default_sort_direction | “asc” | Sets the default sorting direction. |
| sort_by_mapping | [] | Maps a UI field to a different database field. |
| exclude_columns_from_sorting | [] | Prevents specific columns from being sortable. |
| sorting_target | None | Overrides the HTMX target refreshed after sorting. |
For example:
default_sort_field = "created_at"
default_sort_direction = "desc"
sort_by_mapping = [
("owner", "contact_owner__first_name")
]
With this configuration, newly created records can appear first, while the owner column can sort using the related owner’s first name.
5. Adding Filters and Saved Views
Filtering is another important part of a useful CRM list. Instead of writing filtering logic directly inside the view, Horilla allows you to connect a HorillaFilterSet.
For example:
from horilla.contrib.generics.filters import HorillaFilterSet
from .models import Contact
class ContactFilter(HorillaFilterSet):
class Meta:
model = Contact
fields = [
"title",
"contact_source",
"address_state",
]
Then connect the filter to your list view:
filterset_class = ContactFilter
You can also enable quick filters:
enable_quick_filters = True
exclude_quick_filter_fields = ["address_zip"]
Some of the main filtering-related options are:
| Variable | Default | Purpose |
| filterset_class | None | Defines the filter set used by the list. |
| filter_url_push | True | Updates the browser URL when filters change. |
| enable_quick_filters | False | Displays quick-filter options. |
| exclude_quick_filter_fields | [] | Excludes fields from quick filters. |
| owner_filtration | True | Applies owner-based permission filtering. |
| apply_pinned_view_default | True | Uses the user’s pinned view as the default. |
| save_to_list_option | True | Allows users to save the current list configuration. |
This combination is particularly useful in CRM applications because users often need to switch between different record subsets without rebuilding their filters every time.
6. Adding Row Actions and Bulk Operations
List views often need actions such as editing or deleting individual records. HorillaListView allows these actions to be defined using the actions attribute.
For example:
contact_permissions = {
"permission": "contacts.change_contact",
"own_permission": "contacts.change_own_contact",
"owner_field": "contact_owner",
}
actions = [
{
**contact_permissions,
"action": "Edit",
"src": "assets/icons/edit.svg",
"attrs": """
hx-get="{get_edit_url}?new=true"
hx-target="#modalBox"
hx-swap="innerHTML"
onclick="openModal()"
""",
},
{
"action": "Delete",
"src": "assets/icons/a4.svg",
"permission": "contacts.delete_contact",
"own_permission": "contacts.delete_own_contact",
"owner_field": "contact_owner",
"attrs": """
hx-post="{get_delete_url}"
hx-target="#deleteModeBox"
hx-swap="innerHTML"
onclick="openDeleteModeModal()"
""",
},
]
The {get_edit_url} and {get_delete_url} placeholders are resolved for each record, so every row gets an action URL for its own object.
Horilla also supports bulk operations through options such as:
| Variable | Default | Purpose |
| bulk_select_option | True | Displays checkboxes for selecting records. |
| bulk_delete_enabled | True | Enables bulk deletion. |
| bulk_update_option | True | Enables bulk updates. |
| bulk_update_fields | [] | Defines fields available for bulk updates. |
| bulk_update_two_column | False | Displays bulk-update fields in two columns. |
| bulk_export_option | True | Enables exporting selected or filtered records. |
| custom_bulk_actions | [] | Adds custom bulk operations. |
| additional_action_button | [] | Adds additional buttons above the list. |
| max_visible_actions | 4 | Controls how many row actions remain visible before they are grouped. |
These options allow a list to support both individual record actions and operations across multiple records.
7. Making a Column Clickable
In many CRM interfaces, users expect to click a record name and immediately open its detail page.
The col_attrs attribute can be used for this purpose.
For example:
col_attrs = [
{
"first_name": {
"hx-get": "{get_detail_url}",
"hx-target": "#mainContent",
"hx-swap": "outerHTML",
"hx-push-url": "true",
"permission": "contacts.view_contact",
"own_permission": "contacts.view_own_contact",
"owner_field": "contact_owner",
}
}
]
Here, clicking the first name triggers an HTMX request and replaces the main content with the detail view.
The permission settings also ensure that the link respects the user’s access to the particular record.
8. Customizing the Empty State
A list also needs to handle the situation where there are no records.
Horilla provides several options for customizing this state.
| Variable | Default | Purpose |
| no_record_section | True | Controls whether the empty-state section is displayed. |
| no_record_msg | None | Defines the message shown when no records exist. |
| no_record_add_button | None | Adds an option to create a new record. |
| no_found_img | “” | Defines a custom empty-state illustration. |
For example:
no_record_msg = "No contacts yet — add your first one to get started."
You can also show an add button only when the current user has permission:
def no_record_add_button(self):
if self.request.user.has_perm("contacts.add_contact"):
return {
"url": f"{reverse_lazy('contacts:contact_create_form')}?new=true",
"attrs": 'id="contact-create"',
}
return None
This keeps the empty state useful without exposing actions to users who do not have the required permissions.
9. Navigation and Session Behavior
HorillaListView also includes options that help maintain list navigation while users move between records.
| Variable | Default | Purpose |
| store_ordered_ids | False | Stores the ordered IDs of the current filtered list. |
| track_list_navigation | True | Tracks visible record IDs for previous/next navigation. |
| number_of_recent_view | 20 | Controls the number of recent-view entries. |
This becomes useful when a user opens a record from a filtered list and then wants to move to the previous or next record without returning to the list each time.
10. Putting Everything Together
Once you understand the individual options, you can combine them to build a more complete list view.
For example:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from horilla.contrib.generics.views import HorillaListView
from horilla.decorators import htmx_required, permission_required_or_denied
from .models import Contact
from .filters import ContactFilter
@method_decorator(htmx_required, name="dispatch")
@method_decorator(
permission_required_or_denied(
["contacts.view_contact", "contacts.view_own_contact"]
),
name="dispatch",
)
class ContactListView(LoginRequiredMixin, HorillaListView):
model = Contact
paginate_by = 20
view_id = "contact-list"
filterset_class = ContactFilter
search_url = reverse_lazy("contacts:contact_list_view")
main_url = reverse_lazy("contacts:contacts_view")
enable_quick_filters = True
columns = [
"first_name",
"last_name",
"title",
"email",
"contact_number",
"contact_source",
]
header_attrs = [
{
"email": {"style": "width: 250px;"},
"title": {"style": "width: 250px;"},
}
]
bulk_update_fields = [
"title",
"contact_source",
"languages",
"address_city",
"address_state",
"address_zip",
"address_country",
"is_primary",
]
You can then add the row actions, clickable columns, and empty-state behavior described earlier.
11. Creating a Simpler List View
Not every list needs every available feature.
For a small list embedded inside a dashboard, for example, you may want to remove sorting, column visibility, and bulk selection while giving the table a fixed scrollable height.
That can be done with a much smaller configuration:
class ReviewJobListView(LoginRequiredMixin, HorillaListView):
model = ReviewJob
view_id = "review-job-list"
search_url = reverse_lazy("reviews:review_job_list_view")
main_url = reverse_lazy("reviews:review_job_view")
filterset_class = ReviewJobFilter
save_to_list_option = False
list_column_visibility = False
bulk_select_option = False
table_width = False
enable_sorting = False
table_height_as_class = "h-[500px]"
columns = [
"reviews",
"record",
"status",
"approvers",
]
This is one of the advantages of using a generic list-view system: you can keep the configuration small when you do not need the extra functionality.
Creating a feature-rich list page does not have to mean implementing searching, filtering, sorting, permissions, bulk actions, and HTMX behavior separately for every module.
Horilla’s HorillaListView provides a reusable foundation for building these list pages. By setting a few class attributes, you can define the model and columns, connect a filter set, enable sorting, configure row and bulk actions, control column visibility, and manage permission-aware navigation.
The most useful part is that you do not have to configure everything. A simple list can remain simple, while a more advanced CRM module can enable the additional features it needs.
Once you become familiar with the main attributes such as columns, filterset_class, actions, col_attrs, bulk_update_fields, search_url, and view_id, creating new list views becomes much faster and more consistent across the application.
In short, HorillaListView provides a flexible way to build reusable, interactive, and permission-aware list pages without repeatedly writing the same list-view functionality from scratch.