umbral-admin 0.0.12

Auto-generated CRUD admin UI for umbral models.
Documentation
{#
  field_editor(field, value, parent_table, readonly=false, error="")
  -------------------------------------------------------
  Dispatcher macro: renders one form field per SqlType kind.

  Arguments
  ---------
  field        : FormField  — { name, kind, value, nullable, readonly, fk_table }
  value        : string     — current field value (pre-coerced for date/time)
  parent_table : string     — the *owning* model's table name. Used in the FK
                              picker URL (`/admin/api/<parent_table>/<field.name>/options`)
                              because the handler looks the column up on the
                              PARENT model to follow `col.fk_target` to the
                              real target. Passing the target here would 404
                              with "no field `<fk_col>` on `<target>`".
  readonly     : bool       — override to force read-only (e.g. preview mode)
  error        : string     — inline validation message; empty = no error shown
  is_long      : bool       — upgrade Text to textarea for long values (>200 chars)

  Phase-3 cut
  -----------
  ForeignKey renders a plain number input showing the FK id.
  The async searchable combobox (phase 3) replaces this.
#}

{% macro field_editor(field, value, parent_table="", readonly=false, error="", is_long=false) %}
{% set is_readonly = readonly or field.readonly %}
<div class="space-y-1.5 mb-md">
  {# --- Label row --- #}
  <div class="flex items-center gap-xs">
    <label for="f_{{ field.name }}" class="font-label-md text-label-md text-on-surface-variant select-none">
      {{ field.name | replace("_", " ") | title }}
    </label>
    {% if not field.nullable and not is_readonly %}
    <span class="text-error font-label-sm text-label-sm" aria-label="required">*</span>
    {% endif %}
    {% if field.nullable %}
    <span class="text-[10px] text-outline bg-surface-container-high px-1.5 py-0.5 rounded font-label-sm">(nullable)</span>
    {% endif %}
    {% if is_readonly %}
    <span class="text-[10px] text-outline bg-surface-container-high px-1.5 py-0.5 rounded font-label-sm">(read-only)</span>
    {% endif %}
  </div>

  {# --- Input by kind --- #}
  {% if field.kind == "password" %}
    {# Password + confirm pair (create forms only for password_field models) #}
    <div class="space-y-sm">
      <input
        type="password"
        id="f_{{ field.name }}"
        name="{{ field.name }}"
        placeholder="New password"
        required
        autocomplete="new-password"
        class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50"
      />
      <input
        type="password"
        name="{{ field.name }}_confirm"
        placeholder="Confirm password"
        required
        autocomplete="new-password"
        class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50"
      />
    </div>

  {% elif field.kind == "bool" %}
    {# Toggle switch #}
    <label class="inline-flex items-center gap-sm cursor-pointer {% if is_readonly %}opacity-60 pointer-events-none{% endif %}">
      <div class="relative">
        <input
          type="checkbox"
          id="f_{{ field.name }}"
          name="{{ field.name }}"
          value="true"
          {% if value == "true" %}checked{% endif %}
          {% if is_readonly %}disabled{% endif %}
          class="sr-only peer"
        />
        <div class="w-10 h-5 bg-surface-container-high border border-outline-variant rounded-full peer-checked:bg-primary peer-checked:border-primary transition-all"></div>
        <div class="absolute top-0.5 left-0.5 w-4 h-4 bg-outline rounded-full peer-checked:bg-on-primary peer-checked:translate-x-5 transition-all"></div>
      </div>
      <span class="text-body-sm text-on-surface-variant">{{ field.name | replace("_", " ") | title }}</span>
    </label>

  {% elif field.kind == "markdown" or field.kind == "rte" %}
    {# #[umbral(widget = "markdown" | "rte")] — features.md #4.
       A tall textarea carrying data-widget so a progressive-
       enhancement script can later mount a split-pane markdown editor
       or an RTE without changing this markup. With no JS it degrades
       to a usable textarea — the stored value is rendered on the
       display side via the `{{ value | markdown }}` filter. The help
       hint below (from #[umbral(help = "...")]) tells the editor what
       syntax to type.
       Do not emit native required: EasyMDE/Quill hide this textarea,
       and browser validation cannot focus hidden controls. The server
       still validates the field and re-renders sheet errors. #}
    <textarea
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      data-widget="{{ field.kind }}"
      rows="10"
      {% if not field.nullable and not is_readonly %}aria-required="true" data-required="true"{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    >{{ value }}</textarea>

  {% elif field.kind == "code" %}
    {# #[umbral(widget = "code")] — features.md #4. A textarea that
       admin.js enhances into a CodeMirror editor (JSON syntax + line
       numbers); degrades to a mono textarea with no JS. For JSON /
       structured text on any String / Json column.
       CodeMirror hides the original textarea, so required validation
       belongs to the server response path for the same reason as the
       markdown/RTE widgets above. #}
    <textarea
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      data-widget="code"
      rows="10"
      {% if not field.nullable and not is_readonly %}aria-required="true" data-required="true"{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    >{{ value }}</textarea>

  {% elif field.kind == "textarea" or is_long %}
    {# Prose / unbounded TEXT / FullText. Body font, no Format button. #}
    <textarea
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      rows="4"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    >{{ value }}</textarea>

  {% elif field.kind == "json" %}
    {# JSON / Array. Monospace + Format button. #}
    <div class="relative">
      <textarea
        id="f_{{ field.name }}"
        name="{{ field.name }}"
        rows="5"
        {% if not field.nullable and not is_readonly %}required{% endif %}
        {% if is_readonly %}disabled{% endif %}
        class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
      >{{ value }}</textarea>
      {% if not is_readonly %}
      <button
        type="button"
        class="absolute top-2 right-2 px-sm py-xs text-[10px] bg-surface-container-high border border-outline-variant rounded text-on-surface-variant hover:text-on-surface transition-colors font-label-sm"
        onclick="(function(btn){var ta=btn.closest('.relative').querySelector('textarea');try{ta.value=JSON.stringify(JSON.parse(ta.value),null,2);}catch(e){}})(this)"
        title="Format JSON"
      >Format</button>
      {% endif %}
    </div>

  {% elif field.kind == "select" %}
    {# Closed-set enum field (#[umbral(choices)]). Variant list lives on
       field.choices as (value, label) pairs. #}
    <select
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    >
      {% if field.nullable %}<option value=""{% if value == "" %} selected{% endif %}></option>{% endif %}
      {% for opt in field.choices %}
      <option value="{{ opt.value }}"{% if value == opt.value %} selected{% endif %}>{{ opt.label }}</option>
      {% endfor %}
    </select>

  {% elif field.kind == "fk" %}
    {# Async FK combobox — never preloads the whole table.
       Endpoint: {{ admin_base }}/api/<parent_table>/<field.name>/options — the
       handler looks the column up on the parent model to follow
       `col.fk_target` to the target. On edit-mode load,
       /options/resolve?ids=<current> resolves the label.
       `fk_table` (= the target table name) is kept on the dom node
       as `data-fk-table` because the picker JS reads it for label
       formatting; it is NOT what the URL `{table}` segment expects. #}
    {% set fk_table = field.fk_table | default("") %}
    <div class="relative fk-picker" data-field="{{ field.name }}" data-fk-table="{{ fk_table }}">
      <input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
      <div
        data-fk-active
        class="{% if not 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 value %}Loading selected option{% else %}Selected option{% endif %}
            </p>
            <p class="text-[11px] text-on-primary-container tabular-nums" data-fk-active-value>{% if value %}#{{ value }}{% endif %}</p>
          </div>
          {% if field.nullable and not is_readonly %}
          <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 value %}Search to replace {{ field.name | replace('_id', '') | replace('_', ' ') | title }}{% else %}Search {{ field.name | replace('_id', '') | replace('_', ' ') | title }}{% endif %}"
        autocomplete="off"
        class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
        {% if is_readonly %}disabled{% endif %}
        hx-get="{{ admin_base }}/api/{{ parent_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"
      />
      {% if value and not is_readonly %}
      <span id="fk_resolve_{{ field.name }}"
        hx-get="{{ admin_base }}/api/{{ parent_table }}/{{ field.name }}/options/resolve?ids={{ 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">
        {# Populated by HTMX #}
      </div>
    </div>

  {% elif field.kind == "multiselect" %}
    {# MultiChoice<E> — closed-set, multi-valued.
       Each variant gets a checkbox chip. A hidden CSV input is the
       single field actually posted, kept in sync by the small JS
       block below. Pure DOM; no HTMX, no async fetch — every variant
       is known statically from field.choices. #}
    <div class="multichoice-picker" data-field="{{ field.name }}">
      <input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
      {% set selected_csv = "," ~ value ~ "," %}
      <div class="flex flex-wrap gap-xs">
        {% for opt in field.choices %}
        {% set marker = "," ~ opt.value ~ "," %}
        {% set is_selected = marker in selected_csv %}
        <label class="inline-flex items-center gap-xs px-sm py-xs bg-surface-container-low border {% if is_selected %}border-primary text-primary bg-primary/5{% else %}border-outline-variant text-on-surface-variant{% endif %} rounded-full cursor-pointer hover:bg-surface-container-high transition-colors text-body-sm select-none">
          <input type="checkbox"
            data-mc-value="{{ opt.value }}"
            {% if is_selected %}checked{% endif %}
            {% if is_readonly %}disabled{% endif %}
            class="w-3.5 h-3.5 accent-primary"/>
          <span>{{ opt.label }}</span>
        </label>
        {% endfor %}
      </div>
    </div>

  {% elif field.kind == "m2m" %}
    {# M2M chip multi-select picker.
       NOTE: M2M is wired but no Model field uses it until the ORM ships M2M.
       The chip picker is ready for that day. #}
    <div class="fk-picker m2m-picker relative" data-field="{{ field.name }}">
      <div id="chips_{{ field.name }}" class="flex flex-wrap gap-xs mb-xs">
        {# Chips rendered by JS from hidden input #}
      </div>
      <input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
      <input type="text"
        id="m2m_text_{{ field.name }}"
        placeholder="Add {{ field.name | replace('_', ' ') | title }}..."
        autocomplete="off"
        class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
        {% if is_readonly %}disabled{% endif %}
      />
      <div 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-48 overflow-y-auto"></div>
    </div>

  {% elif field.kind == "number" %}
    {# Integer / BigInt / Real / Double #}
    <input
      type="number"
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      value="{{ value }}"
      step="any"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-data-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all tabular-nums placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% elif field.kind == "date" %}
    <input
      type="date"
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      value="{{ value }}"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% elif field.kind == "time" %}
    <input
      type="time"
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      value="{{ value }}"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% elif field.kind == "datetime-local" %}
    <input
      type="datetime-local"
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      value="{{ value }}"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% elif field.kind == "file" %}
    {# FileField / #[umbral(widget="file")] — native file upload. The
       stored value is the storage key; an empty file part on submit is
       skipped server-side so an edit without re-choosing keeps the
       current key. A `<input type="file">` carries no `value`, so the
       current key is surfaced as a download link instead. #}
    {% if field.value %}
    <p class="text-body-sm text-on-surface-variant mb-xs flex items-center gap-xs">
      <i data-lucide="paperclip" class="w-[14px] h-[14px] flex-shrink-0"></i>
      <span>Current:</span>
      <a href="{{ field.value_url }}" target="_blank" rel="noopener" class="text-primary hover:underline truncate">{{ 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 is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% elif field.kind == "image" %}
    {# ImageField / #[umbral(widget="image")] — native file upload with a
       thumbnail preview of the current value. Same empty-part-skip
       semantics as `file`; `accept="image/*"` nudges the picker toward
       images. #}
    {% if field.value %}
    <div class="mb-sm">
      <img src="{{ field.value_url }}" alt="{{ field.name }}" class="rounded-xl border border-outline-variant max-h-40 w-auto object-contain bg-surface-container-low" />
      <p class="text-body-sm text-on-surface-variant mt-xs flex items-center gap-xs">
        <i data-lucide="image" class="w-[14px] h-[14px] flex-shrink-0"></i>
        <a href="{{ field.value_url }}" target="_blank" rel="noopener" class="text-primary hover:underline">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 is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />

  {% else %}
    {# Default: text input (Text, Uuid, Inet, etc.) #}
    <input
      type="text"
      id="f_{{ field.name }}"
      name="{{ field.name }}"
      value="{{ value }}"
      {% if not field.nullable and not is_readonly %}required{% endif %}
      {% if is_readonly %}disabled{% endif %}
      class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
    />
  {% endif %}

  {# --- Help text (#[umbral(help = "...")]) --- #}
  {% if field.help %}
  <p class="text-body-sm text-on-surface-variant mt-0.5">{{ field.help }}</p>
  {% endif %}

  {# --- Inline validation error --- #}
  {% if error %}
  <p class="text-body-sm text-error mt-0.5 flex items-center gap-xs">
    <i data-lucide="alert-circle" class="w-[14px] h-[14px] flex-shrink-0"></i>
    {{ error }}
  </p>
  {% endif %}
</div>
{% endmacro %}