{#
rows_fragment.html — HTMX-only fragment for #table-body swap.
Returned by GET {{ admin_base }}/{table}/rows?search=&filter=&sort=&order=&page=&page_size=
Contains only the <tr> rows + pagination footer <tr>.
The enclosing <tbody id="table-body"> lives in data_table.html (the full page).
Variables passed from the handler:
table, model_name, rows, pk, columns, pagination,
active_filters, search_val, sort_col, sort_order,
actions, inline_edit_fields
#}
{% from "admin/_macros/pagination.html" import pagination_footer %}
{# `filter_qs` arrives from the handler precomputed as one
`&filter_<field>=<comma-joined>` per UNIQUE field. See data_table.html
for the rationale (HashMap-based extractor collapses repeated keys). #}
{% set filter_qs = filter_qs | default("") %}
{#
#115: out-of-band swap of the active-filters chip strip. The HTMX
triggers that target `#table-body` (filter dialog Apply, chip x,
search input, sort/page change) all bring back this fragment, so
the OOB block lets the chip strip update in lock-step with the
row swap without the trigger having to remember to refresh both
elements explicitly.
When `active_filters` is empty the OOB block emits an EMPTY
wrapper — the strip's contents vanish but the wrapper div stays
in place (with the same id), so subsequent OOB swaps still have a
target.
CRITICAL: this fragment is HTMX-swapped into `<tbody id="table-body">`.
A bare `<div>` at the top of the response sits inside <tbody> in
the browser's HTML parser, which aborts table-mode and collapses
every <td colspan="N"> in the rows that follow into column 1
(post-#115 ship bug — empty-state + pagination got cramped into
the first column). The HTMX-documented fix is to wrap non-row OOB
content in a `<template>` tag so the parser treats it as inert;
HTMX still finds the inner element by id+`hx-swap-oob` and
routes it to `#dt-active-filters-strip`.
See https://htmx.org/attributes/hx-swap-oob/#using-template-tags
#}
<template>
<div id="dt-active-filters-strip"
hx-swap-oob="outerHTML"
class="flex flex-wrap items-center gap-xs {% if active_filters and active_filters | length > 0 %}mb-sm{% endif %}">
{% if active_filters and active_filters | length > 0 %}
<span class="font-label-sm text-label-sm text-outline uppercase tracking-wider">Active filters:</span>
{% for af in active_filters %}
{% set remove_url -%}
{{ admin_base }}/{{ table }}/rows?search={{ search_val | urlencode }}&sort={{ sort_col | urlencode }}&order={{ sort_order | urlencode }}{{ af.remove_qs | default("") }}
{%- endset %}
<div class="inline-flex items-center gap-xs bg-primary-container/10 border border-primary/20 text-primary px-sm py-xs rounded-full font-label-sm text-label-sm">
<span class="text-on-surface-variant">{{ af.field | replace("_", " ") }}:</span>
<span>{{ af.display | default(af.value) }}</span>
<a
href="{{ remove_url }}"
hx-get="{{ remove_url }}"
hx-target="#table-body"
hx-swap="innerHTML"
hx-push-url="true"
aria-label="Remove filter {{ af.field }}"
>
<i data-lucide="x" class="w-3 h-3 cursor-pointer hover:text-on-primary-container transition-colors"></i>
</a>
</div>
{% endfor %}
{% endif %}
</div>
</template>
{% if rows %}
{% for row in rows %}
<tr
class="hover:bg-surface-container-lowest transition-colors group cursor-pointer dt-row"
data-row-id="{{ row[pk] }}"
hx-get="{{ admin_base }}/{{ table }}/{{ row[pk] }}/sheet"
hx-target="#umbral-sheet-slot"
hx-swap="innerHTML"
hx-push-url="false"
hx-trigger="click[!event.target.closest('button') && !event.target.closest('input')]"
>
<td class="px-md py-md" onclick="event.stopPropagation()">
<input
type="checkbox"
name="selected"
value="{{ row[pk] }}"
form="bulk-action-form"
class="w-4 h-4 rounded border-outline-variant bg-surface-container text-primary focus:ring-primary cursor-pointer row-cb"
onchange="umbral.onRowCheck()"
/>
</td>
{% for col in columns %}
<td class="px-md py-md {% if not loop.first %}hidden lg:table-cell{% endif %} dt-col" data-col="{{ col.name }}"
{% if col.name in inline_edit_fields and (perms is undefined or perms.can_change) %}
hx-trigger="dblclick"
hx-get="{{ admin_base }}/{{ table }}/{{ row[pk] }}/cell/{{ col.name }}/edit"
hx-target="this"
hx-swap="innerHTML"
title="Double-click to edit"
style="cursor: default"
{% endif %}
>
{% set val = row[col.name] %}
{% if col.name == "published" or col.name == "is_active" or col.name == "is_staff" %}
{% if val == "true" %}
<span class="px-sm py-xs bg-primary-container/10 text-primary border border-primary/20 rounded-full font-label-sm text-label-sm">Yes</span>
{% else %}
<span class="px-sm py-xs bg-surface-container-highest text-on-surface-variant border border-outline-variant rounded-full font-label-sm text-label-sm">No</span>
{% endif %}
{% elif val == "" or val is none %}
<span class="text-outline text-body-sm italic">—</span>
{% else %}
<span class="text-on-surface text-body-md tabular-nums">{{ val }}</span>
{% endif %}
</td>
{% endfor %}
{# Sticky actions column — always rendered so the column never disappears on HTMX swap #}
<td
class="px-md py-md sticky right-0 bg-surface transition-colors group-hover:bg-surface-container-lowest text-right"
onclick="event.stopPropagation()"
>
<div class="flex items-center justify-end gap-xs">
{# gaps2 #35: trash-view row affordances mirror data_table.html —
Restore + Delete-permanently instead of preview / edit / soft-
delete. #}
{% if trash %}
{% if perms is undefined or perms.can_change %}
<button type="button"
hx-post="{{ admin_base }}/{{ table }}/actions/restore_selected"
hx-vals='{"ids": ["{{ row[pk] }}"]}' hx-swap="none"
title="Restore"
onclick="setTimeout(function(){htmx.ajax('GET','{{ admin_base }}/{{ table }}/rows?trash=1',{target:'#table-body',swap:'innerHTML'})},150)"
class="p-sm text-on-surface-variant hover:text-primary transition-colors rounded-lg hover:bg-surface-container-high">
<i data-lucide="archive-restore" class="w-4 h-4"></i>
</button>
{% endif %}
{% if perms is undefined or perms.can_delete %}
<button type="button"
hx-post="{{ admin_base }}/{{ table }}/actions/delete_permanently"
hx-vals='{"ids": ["{{ row[pk] }}"]}' hx-swap="none"
title="Delete permanently"
onclick="if(!confirm('This will PERMANENTLY delete this row. It cannot be restored. Continue?'))return false;setTimeout(function(){htmx.ajax('GET','{{ admin_base }}/{{ table }}/rows?trash=1',{target:'#table-body',swap:'innerHTML'})},150)"
class="p-sm text-on-surface-variant hover:text-error transition-colors rounded-lg hover:bg-surface-container-high">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
{% endif %}
{% else %}
<button type="button"
hx-get="{{ admin_base }}/{{ table }}/{{ row[pk] }}/sheet"
hx-target="#umbral-sheet-slot" hx-swap="innerHTML" hx-push-url="false"
title="Preview"
class="p-sm text-on-surface-variant hover:text-primary transition-colors rounded-lg hover:bg-surface-container-high">
<i data-lucide="eye" class="w-4 h-4"></i>
</button>
{% if perms is undefined or perms.can_change %}
<button type="button"
hx-get="{{ admin_base }}/{{ table }}/{{ row[pk] }}/edit-sheet"
hx-target="#umbral-sheet-slot" hx-swap="innerHTML" hx-push-url="false"
title="Edit"
class="p-sm text-on-surface-variant hover:text-primary transition-colors rounded-lg hover:bg-surface-container-high">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
{% endif %}
{% if perms is undefined or perms.can_delete %}
<button type="button"
hx-get="{{ admin_base }}/{{ table }}/{{ row[pk] }}/_confirm-delete"
hx-target="#umbral-dialog-slot" hx-swap="innerHTML"
title="Delete"
class="p-sm text-on-surface-variant hover:text-error transition-colors rounded-lg hover:bg-surface-container-high">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
{% endif %}
{% endif %}
{# Custom row-scope actions (up to 2 inline, rest in overflow menu) #}
{% set row_actions = [] %}
{% for action in actions %}
{% if action.scope == "row" or action.scope == "both" %}
{% set row_actions = row_actions | list + [action] %}
{% endif %}
{% endfor %}
{% for action in row_actions[:2] %}
<button type="button"
hx-post="{{ admin_base }}/{{ table }}/actions/{{ action.key }}"
hx-vals='{"ids": [{{ row[pk] }}]}'
hx-swap="none"
title="{{ action.label }}"
class="p-sm transition-colors rounded-lg hover:bg-surface-container-high {% if action.variant == 'danger' %}text-error hover:text-error{% else %}text-on-surface-variant hover:text-primary{% endif %}"
{% if action.confirm %}onclick="if(!confirm('{{ action.confirm | escapejs }}'))return false;"{% endif %}
>
<i data-lucide="{{ action.icon }}" class="w-4 h-4"></i>
</button>
{% endfor %}
{% if row_actions | length > 2 %}
<div class="relative">
<button type="button"
onclick="var m=this.nextElementSibling;m.classList.toggle('hidden');event.stopPropagation()"
class="p-sm text-on-surface-variant hover:text-primary transition-colors rounded-lg hover:bg-surface-container-high"
title="More actions">
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
<div class="hidden absolute right-0 bottom-full mb-xs z-40 bg-surface-container border border-outline-variant rounded-xl shadow-lg py-xs min-w-[160px]">
{% for action in row_actions[2:] %}
<button type="button"
hx-post="{{ admin_base }}/{{ table }}/actions/{{ action.key }}"
hx-vals='{"ids": [{{ row[pk] }}]}'
hx-swap="none"
{% if action.confirm %}onclick="if(!confirm('{{ action.confirm | escapejs }}'))return false;"{% endif %}
class="w-full flex items-center gap-sm px-md py-sm text-left hover:bg-surface-container-high font-label-md text-label-md {% if action.variant == 'danger' %}text-error{% else %}text-on-surface{% endif %} transition-colors">
<i data-lucide="{{ action.icon }}" class="w-4 h-4"></i>
{{ action.label }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="{{ columns | length + 2 }}" class="px-md py-xl text-center">
{% if search_val or (active_filters and active_filters | length > 0) %}
<div class="flex flex-col items-center gap-md">
<i data-lucide="search-x" class="w-10 h-10 text-outline opacity-50"></i>
<p class="text-body-md text-on-surface-variant">No {{ model_name | lower }} records match your search.</p>
<a href="{{ admin_base }}/{{ table }}/"
class="px-lg py-sm bg-surface-container-high border border-outline-variant rounded-xl text-on-surface font-label-md text-label-md hover:bg-surface-container-highest transition-colors">
Clear filters
</a>
</div>
{% else %}
<div class="flex flex-col items-center gap-md">
<i data-lucide="database" class="w-10 h-10 text-outline opacity-50"></i>
<p class="text-body-md text-on-surface-variant">No {{ model_name | lower }} records yet.</p>
</div>
{% endif %}
</td>
</tr>
{% endif %}
{# Pagination footer (shared macro; long numbered form, same as first load) #}
{{ pagination_footer(admin_base, table, columns, search_val, filter_qs, sort_col, sort_order, pagination) }}