umbral-admin 0.0.12

Auto-generated CRUD admin UI for umbral models.
Documentation
{#
  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) }}