Detail pages are great when a record deserves a full page, but a lot of the time, a user just wants a quick look. They’re scanning a list of holidays, mail configurations, or duplicate rules, and clicking through to a whole new page just to check one value feels heavy. What they want is a fast, in-place preview: click a row, see the details in a popup, close it, move on.
Horilla CRM solves this with HorillaModalDetailView, a generic view that renders a single record’s fields inside a modal dialog, complete with a header, action buttons, and even prev/next navigation so users can browse records one at a time without ever leaving the list.
What Does HorillaModalDetailView Do?
HorillaModalDetailView is a Django DetailView subclass that, instead of rendering a full page, renders a compact single_detail_view.html fragment meant to be swapped into a shared modal shell. Visually, it produces:
- A header row with a title and a close button
- An optional “profile” header with an avatar, a title, and a subtitle pulled from the record
- A two-column grid of label/value field rows
- A row of action buttons (edit, delete, custom actions, whatever you define)
- Previous/next buttons that let the user step through the records currently loaded in the list, without closing the modal
It’s the modal counterpart to HorillaTabView in spirit; both are generic, data-driven view classes, but where HorillaTabView lays out multiple lazy-loaded panes inline in the page, HorillaModalDetailView lays out one record’s fields inside a popup.
It’s worth being precise here: Horilla does not have a single class that combines tabs and a modal into one HorillaModalDetailView is a flat, single-panel view. If you need tabs inside a modal, you’d compose HorillaTabView’s markup inside the modal shell yourself; that’s a distinct pattern from what this class does out of the box.
Defining the Modal View
At its simplest, you inherit from HorillaModalDetailView, set a model, and describe which fields belong in the body:
class HolidayDetailView(LoginRequiredMixin, HorillaModalDetailView):
model = Holiday
title = _("Holiday Detail")
header = {
"title": "name",
"subtitle": "date",
"avatar": "",
}
body = [
"name",
"date",
"recurring",
("Created By", "created_by"),
]
That’s enough to get a working popup: a title bar, a “name/date” profile header, and four label/value rows underneath.
Understanding the Configuration
A few class attributes drive the whole render:
- title: the string shown in the modal’s header bar.
- header: a dict of title, subtitle, and avatar, each naming a field (or template-accessible attribute) on the instance. If header is falsy, that profile block is skipped entirely.
- body: the list of fields rendered as label/value rows. Horilla normalizes each entry for you: a bare string like “date” resolves its label from the model field’s verbose_name; a (label, field_name) tuple lets you override the label explicitly; and a string like “get_status_display” is recognized and resolved back to the underlying field’s verbose name automatically.
- actions / action_method: actions is a list of dicts describing buttons (icon, label, click attributes); action_method is an alternative where a single model method or property renders its own action markup directly.
- ids_key: the query parameter name (“instance_ids” by default) used to pass along the ordered set of record IDs from the list view, which is what powers prev/next.
There is another important detail: HorillaModalDetailView doesn’t bake in authentication, htmx-only dispatch, or permission checks. Every real subclass in Horilla stacks these explicitly:
@method_decorator(htmx_required, name=”dispatch”)
@method_decorator(
permission_required_or_denied([“core.view_holiday”]), name=”dispatch”
)
class HolidayDetailView(LoginRequiredMixin, HorillaModalDetailView):
…
This is a convention, not something enforced by the base class, so it’s worth copying deliberately rather than assuming it’s free.
How the Body Fields Get Resolved
get_body_fields() is the method doing the normalization work described above. You rarely need to override it, but it’s worth understanding what it accepts:
body = [
"name", # resolves label via model field's verbose_name
("Custom Label", "email"), # explicit label override
"get_status_display", # resolves label from the underlying "status" field
"some_property", # falls back to a title-cased field name,
# unless the property defines short_description
]
If a body entry names a property or method that has a Django-admin-style short_description attribute, that’s used as the label, so you can reuse the same convention you’d use for list_display in Django admin.
Adding Prev/Next Navigation
This is where HorillaModalDetailView goes beyond a plain popup. When a list view stores the ordered set of visible record IDs in the session, the modal automatically picks them up and renders prev/next buttons:
def get_queryset(self):
queryset = super().get_queryset()
instance_ids = self.request.session.get(self.ordered_ids_key, [])
if instance_ids:
queryset = queryset.filter(pk__in=instance_ids)
return queryset
get_context_data() then finds the current record’s neighbors in that ordered list and builds next_url / previous_url for the template:
if instance_ids:
prev_id, next_id = closest_numbers(instance_ids, pk)
context.update({
"instance_ids": str(instance_ids),
"ids_key": self.ids_key,
"next_url": reverse_lazy(full_url_name, kwargs={key: next_id}),
"previous_url": reverse_lazy(full_url_name, kwargs={key: prev_id}),
})
The prev/next buttons in the template target #detailModalBox directly, so clicking them swaps in the new record’s HTML without closing and reopening the modal shell, the popup stays open, only its content changes.
Wiring It Up: Opening the Modal From a List
The modal view itself never opens on its own; it’s the list row that triggers it. Horilla’s modal shells are pre-declared once, in a shared partial included in the base layout:
<div id="detailModal" class="fixed inset-0 ... hidden items-center justify-center z-50">
<div id="detailModalBox" class="bg-white w-full max-w-md rounded-lg ... modal-box"></div>
</div>
#detailModal is the dimmed overlay (hidden by default); #detailModalBox is the empty box that HTMX fills in.
A list view wires a row to open this modal through col_attrs, which Horilla’s generic list-view rendering picks up and applies as raw HTML attributes on the row:
@cached_property
def col_attrs(self):
attrs = {
"hx-get": f"{{get_detail_url}}?instance_ids={query_string}",
"hx-target": "#detailModalBox",
"hx-swap": "innerHTML",
"hx-push-url": "false",
"hx-on:click": "openDetailModal();",
"style": "cursor:pointer",
}
return [{"name": {**attrs}}]
{get_detail_url} is resolved per row against a method on the model itself:
def get_detail_url(self):
return reverse_lazy(“core:holiday_detail_view”, kwargs={“pk”: self.pk})
So a single click does two things at once: HTMX fetches the detail view and swaps its HTML into #detailModalBox, while the inline hx-on:click calls openDetailModal(), which un-hides the overlay and animates the box in. Both openDetailModal() and closeDetailModal() (called from the modal’s own close button) are thin wrappers around a shared ModalManager object that also handles stacking multiple modals and locking body scroll while one is open.
Handling the Empty Case
Because the modal is often opened from a stored list of IDs, it’s possible for a record to no longer exist by the time it’s requested (deleted by someone else, for instance). HorillaModalDetailView handles this gracefully:
def get(self, request, *args, **kwargs):
if not self.request.GET.get(self.ids_key) and not self.request.session.get(
self.ordered_ids_key
):
self.request.session[self.ordered_ids_key] = []
response = super().get(request, *args, **kwargs)
if not self.instance and self.empty_template:
return render(request, self.empty_template, context=self.get_context_data())
if not self.instance:
messages.error(request, _("The requested record does not exist."))
return ScriptResponse(reload=True)
return response
If you set empty_template, that’s rendered instead of erroring out; otherwise the user gets an error message, and the page reloads.
Extending an Existing Modal View
Because body, header, and actions are just class attributes, extending a modal view in a subclass or from an app extension is usually just a matter of appending to the list:
class ExtendedHolidayDetailView(HolidayDetailView):
body = HolidayDetailView.body + [
("Approved By", "approved_by"),
]
The same applies to actions, if you want to add a button without redefining the whole view.
HorillaModalDetailView gives you a fast way to preview a single record without leaving the page it was clicked from: a title and profile header, a normalized list of body fields, action buttons, and, when the calling list view provides an ordered set of IDs, prev/next navigation that lets users step through records without reopening the modal each time. Whether you’re building a quick-look popup for a settings list, a duplicate-record reviewer, or anything in between, the pattern stays the same: define model, header, and body, wire a list row’s col_attrs to fetch it into #detailModalBox, and let the base class handle the rest.