{% extends "admin/base.html" %}
{% from "admin/_macros/inlines.html" import inline_formset %}
{% block title %}{{ verb }} {{ model.name }} — {{ site_title }}{% endblock %}
{% block content %}
<div class="breadcrumb">
<a href="{{ admin_base }}">admin</a> / <a href="{{ admin_base }}/{{ model.table }}/">{{ model.name }}</a> / {{ verb }}
</div>
<div class="panel">
<h2>{{ verb }} {{ model.name }}{% if row %} #{{ row[pk] }}{% endif %}</h2>
{% if error %}<p style="color:#b8453d">{{ error }}</p>{% endif %}
{# Wave 4: switch to multipart only when a file/image field is on the
form, so plain admin forms keep the default urlencoded encoding
(and work without a Storage backend wired). #}
{% set _enc = namespace(file=false) %}
{% for field in fields %}{% if field.kind == "file" or field.kind == "image" %}{% set _enc.file = true %}{% endif %}{% endfor %}
<form method="post" action="{{ action }}"{% if _enc.file %} enctype="multipart/form-data"{% endif %}>
{% for field in fields %}
<div class="field">
<label for="f_{{ field.name }}">
{{ field.name }}{% if field.nullable %} <span class="meta">(nullable)</span>{% endif %}
</label>
{% if field.kind == "bool" %}
<div class="checkbox-row">
<input type="checkbox" id="f_{{ field.name }}" name="{{ field.name }}" value="true"{% if field.value == "true" %} checked{% endif %}>
<label for="f_{{ field.name }}">{{ field.name }}</label>
</div>
{% elif field.kind == "select" %}
<select id="f_{{ field.name }}" name="{{ field.name }}">
{% if field.nullable %}<option value="">—</option>{% endif %}
{% for opt in field.choices %}
<option value="{{ opt.value }}"{% if field.value == opt.value %} selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
{% elif field.kind == "fk" %}
{% set fk_table = field.fk_table | default("") %}
<div class="fk-picker relative" data-field="{{ field.name }}" data-fk-table="{{ fk_table }}">
<input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ field.value }}" />
<div
data-fk-active
class="{% if not field.value %}hidden {% endif %}mb-xs rounded-xl border border-primary/20 bg-primary-container px-md py-sm"
>
<div class="flex items-start justify-between gap-sm">
<div class="min-w-0">
<p class="text-label-sm text-on-primary-container uppercase tracking-wider">Selected</p>
<p class="text-body-sm font-medium text-on-primary-container truncate" data-fk-active-label>
{% if field.value %}Loading selected option{% else %}Selected option{% endif %}
</p>
<p class="text-[11px] text-on-primary-container tabular-nums" data-fk-active-value>{% if field.value %}#{{ field.value }}{% endif %}</p>
</div>
{% if field.nullable %}
<button type="button" data-fk-clear class="flex-shrink-0 inline-flex items-center gap-xs rounded-lg border border-primary/20 bg-surface-container px-sm py-xs text-label-sm text-on-surface-variant hover:text-on-surface transition-colors">Clear</button>
{% endif %}
</div>
</div>
<input type="text" id="fk_text_{{ field.name }}" placeholder="{% if field.value %}Search to replace{% else %}Search{% endif %}..." autocomplete="off" value=""
hx-get="{{ admin_base }}/api/{{ model.table }}/{{ field.name }}/options"
hx-trigger="input changed delay:250ms, focus"
hx-target="#fk_options_{{ field.name }}"
hx-swap="innerHTML"
hx-include="this"
name="search"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/>
{% if field.value %}
<span id="fk_resolve_{{ field.name }}"
hx-get="{{ admin_base }}/api/{{ model.table }}/{{ field.name }}/options/resolve?ids={{ field.value }}"
hx-trigger="load"
hx-swap="none"
hx-on:htmx:after-request="if(window.umbral&&umbral.fkResolve)umbral.fkResolve('{{ field.name }}', event)"
></span>
{% endif %}
<div id="fk_options_{{ field.name }}" class="fk-options hidden absolute left-0 right-0 top-full mt-xs z-30 bg-surface-container border border-outline-variant rounded-xl shadow-lg max-h-64 overflow-y-auto">
</div>
</div>
{% elif field.kind == "password" %}
<input type="password" id="f_{{ field.name }}" name="{{ field.name }}" placeholder="New password" required autocomplete="new-password" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 8px;" />
<input type="password" name="{{ field.name }}_confirm" placeholder="Confirm password" required autocomplete="new-password" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" />
{% elif field.kind == "textarea" %}
<textarea id="f_{{ field.name }}" name="{{ field.name }}" rows="4" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">{{ field.value }}</textarea>
{% elif field.kind == "json" %}
<textarea id="f_{{ field.name }}" name="{{ field.name }}" rows="5" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace;">{{ field.value }}</textarea>
{% elif field.kind == "file" %}
{% if field.value %}
<p class="meta">Current: <a href="{{ field.value_url }}" target="_blank" rel="noopener">{{ field.value }}</a></p>
{% endif %}
<input type="file" id="f_{{ field.name }}" name="{{ field.name }}"{% if not field.nullable and not field.value and not field.readonly %} required{% endif %}{% if field.readonly %} disabled{% endif %} style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" />
{% elif field.kind == "image" %}
{% if field.value %}
<div style="margin-bottom:8px">
<img src="{{ field.value_url }}" alt="{{ field.name }}" style="max-height:160px; width:auto; border:1px solid #ccc; border-radius:8px; display:block;" />
<p class="meta"><a href="{{ field.value_url }}" target="_blank" rel="noopener">View full image</a></p>
</div>
{% endif %}
<input type="file" id="f_{{ field.name }}" name="{{ field.name }}" accept="image/*"{% if not field.nullable and not field.value and not field.readonly %} required{% endif %}{% if field.readonly %} disabled{% endif %} style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" />
{% elif field.kind == "multiselect" %}
<div class="multichoice-picker" data-field="{{ field.name }}">
<input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ field.value }}" />
{% set selected_csv = "," ~ field.value ~ "," %}
<div style="display:flex; flex-wrap:wrap; gap:4px;">
{% for opt in field.choices %}
{% set marker = "," ~ opt.value ~ "," %}
{% set is_selected = marker in selected_csv %}
<label style="display:inline-flex; align-items:center; gap:4px; padding:4px 8px; border:1px solid #ccc; border-radius:12px; cursor:pointer;{% if is_selected %} background:#e0f2fe; border-color:#0284c7;{% endif %}">
<input type="checkbox" data-mc-value="{{ opt.value }}" {% if is_selected %}checked{% endif %} />
<span>{{ opt.label }}</span>
</label>
{% endfor %}
</div>
</div>
{% else %}
<input
type="{% if field.kind == "number" %}number{% elif field.kind == "date" %}date{% elif field.kind == "time" %}time{% elif field.kind == "datetime-local" %}datetime-local{% else %}text{% endif %}"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ field.value }}"
{% if not field.nullable %}required{% endif %}
{% if field.readonly %}readonly{% endif %}
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
>
{% endif %}
{% if field.error %}<p class="field-error" style="color:#b8453d; margin-top:4px; font-size:13px;">{{ field.error }}</p>{% endif %}
</div>
{% endfor %}
{# BUG-16 admin: M2M selections. One section per
`Model::M2M_RELATIONS` entry; renders a checkbox list of
every candidate child with currently-selected ones
pre-checked. The POST handler reads back `m2m_<field>`
repeated values and writes them through
`set_junction_dynamic`. #}
{% if m2m_fields %}
{% for m in m2m_fields %}
<div class="field m2m-field-picker" data-page-size="12">
<label>{{ m.label }} <span class="meta">({{ m.candidates | length }} available)</span></label>
<div class="rounded-xl border border-outline-variant bg-surface-container-low p-sm mb-sm">
<div class="flex items-center justify-between gap-sm mb-xs">
<span class="text-label-sm text-outline uppercase tracking-wider">Active selection</span>
<span class="text-label-sm text-outline" data-m2m-count>{{ m.selected_values | length }} selected</span>
</div>
<div class="flex flex-wrap gap-xs min-h-7" data-m2m-selected></div>
<p class="text-body-sm text-outline py-xs" data-m2m-selected-empty>No rows selected.</p>
</div>
{% if not m.candidates %}
<p class="meta">No candidate rows in the target table.</p>
{% else %}
<input type="text" data-m2m-search placeholder="Search {{ m.label | replace('_', ' ') | title }}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 8px;" />
<div class="m2m-checkbox-list" style="max-height: 220px; overflow-y: auto; border: 1px solid #ccc; border-radius: 4px; padding: 8px;">
{% for opt in m.candidates %}
{% set is_selected = opt.value in m.selected_values %}
<label data-m2m-option data-label="{{ opt.label }}" style="display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer;">
<input type="checkbox"
name="m2m_{{ m.name }}"
value="{{ opt.value }}"
{% if is_selected %}checked{% endif %} />
<span>{{ opt.label }}</span>
</label>
{% endfor %}
<p class="hidden meta" data-m2m-empty>No matching rows.</p>
</div>
<div class="flex items-center justify-between gap-sm mt-sm">
<button type="button" data-m2m-prev class="btn">Previous</button>
<span class="text-label-sm text-outline tabular-nums" data-m2m-page>Page 1</span>
<button type="button" data-m2m-next class="btn">Next</button>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{# Inlines: edit reverse-FK children right on the parent change
form. The formset markup is the ONE shared `inline_formset`
macro (also used by the slide-over sheet) so the field names
the `crud::create` / `crud::update` handler parses are
byte-identical in both surfaces. #}
{{ inline_formset(inlines) }}
<div class="actions">
{# Save is gated on the relevant verb: a new-row form needs
can_add, an existing-row form needs can_change. The handler
re-validates either way; this hides the affordance. #}
{% set _save_ok = (perms is undefined)
or (verb == "Create" and perms.can_add)
or (verb != "Create" and perms.can_change) %}
{% if _save_ok %}
<button type="submit" class="btn btn-primary">Save</button>
{% endif %}
<a class="btn" href="{{ admin_base }}/{{ model.table }}/">Cancel</a>
</div>
</form>
</div>
{% endblock %}