Files
komp_ac/web/templates/pages/add_table/builder.html
2026-08-12 18:57:10 +02:00

370 lines
19 KiB
HTML

{#
POST /admin/tables/builder — crate::pages::add_table::ui::BuilderFragment.
The whole builder, which is also what `add_table.html` embeds on first load.
Every control posts the entire draft back to /admin/tables/builder and swaps
this markup in again, so the rules live on the server exactly as they do in
the TUI client. The hidden inputs in the columns, relations and display
sections are what carry the draft across requests.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{#
A failure is a dialog, not an inline alert: the builder is taller than the
viewport, so an alert at the top is off screen for anyone who has scrolled
down to the column list or the Create table button. The alert stays too, as
what is left on the page after the dialog is dismissed.
#}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call toast::success("Draft updated", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
<section class="builder-section">
<h2>Table details</h2>
<div class="form-grid">
{# Penguin UI custom radio, adapted from radio/custom-radio.html with this form's values and htmx behaviour. #}
<fieldset class="col-span-full m-0 border-0 p-0">
<legend class="mb-2 text-xs text-on-surface">Where should this table be available?</legend>
<div class="grid grid-cols-1 gap-2">
<label class="relative flex items-center gap-3 rounded-radius border border-outline bg-surface-alt px-3 py-2.5 text-on-surface transition-colors has-checked:border-primary has-checked:bg-primary/5 has-checked:text-on-surface-strong has-focus:outline-2 has-focus:outline-offset-2 has-focus:outline-primary">
<input id="scope-profile" type="radio" name="global" value="false" class="peer sr-only"
aria-describedby="scope-profile-description"
{% if !page.draft.global %}checked{% endif %}
hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="invisible size-5 shrink-0 peer-checked:visible">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path>
</svg>
<span class="flex flex-col"><strong class="text-sm font-medium">One profile</strong><small id="scope-profile-description">Only the selected profile can use this table.</small></span>
</label>
<label class="relative flex items-center gap-3 rounded-radius border border-outline bg-surface-alt px-3 py-2.5 text-on-surface transition-colors has-checked:border-primary has-checked:bg-primary/5 has-checked:text-on-surface-strong has-focus:outline-2 has-focus:outline-offset-2 has-focus:outline-primary">
<input id="scope-global" type="radio" name="global" value="true" class="peer sr-only"
aria-describedby="scope-global-description"
{% if page.draft.global %}checked{% endif %}
hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="invisible size-5 shrink-0 peer-checked:visible">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path>
</svg>
<span class="flex flex-col"><strong class="text-sm font-medium">All profiles</strong><small id="scope-global-description">Every profile can use this shared table.</small></span>
</label>
</div>
</fieldset>
{% if !page.draft.global %}
<label>Which profile?
<select name="profile_name" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">Choose a profile</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selected_profile() == profile.as_str() %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
<option value="__new__" {% if page.draft.creating_new_profile %}selected{% endif %}>+ New profile…</option>
</select>
</label>
{% else %}
<input type="hidden" name="profile_name" value="{{ page.draft.profile_name }}">
{% endif %}
{% if page.draft.show_profile_name_input() %}
<label>New profile name
<input name="profile_name_input" value="{{ page.draft.profile_name_input }}" placeholder="bookkeeping">
</label>
{% endif %}
{% if page.draft.show_accounting_currency() %}
<label>Accounting currency
<input name="accounting_currency" value="{{ page.draft.accounting_currency }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
{% else %}
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
{% endif %}
{#
No hx-trigger here. A text input fires `change` on blur, so mousing into
the next field would swap the whole builder out from under the click that
is still in flight: the click lands on a detached node and the field only
takes focus on a second click. Nothing rendered depends on the table name
on its own — it filters the table out of its own link-target list, and
that list is only rendered after a column-type `change`, which posts the
current name along with the rest of the form.
#}
<label>Table name
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice">
</label>
</div>
</section>
<section class="builder-section">
<h2>Add a column</h2>
<div class="form-grid">
{#
A compound choice is a definition row, not a column: it is named after
its own type and expands into companions the server creates. There is
nothing to name, so the field is replaced by what the choice will add.
#}
{% if !page.draft.columns.pending_is_compound() %}
<label>{% if page.draft.columns.show_link_target() %}Link alias{% else %}Column name{% endif %}
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
{% if page.draft.columns.show_link_target() %}<small>The alias is the column name used to distinguish this relationship.</small>{% endif %}
</label>
{% endif %}
<label>Column type
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">Choose a type</option>
{% for column_type in column_types %}
<option value="{{ column_type }}" {% if page.draft.columns.type_input == *column_type %}selected{% endif %}>{% if *column_type == "link" %}FKlink{% else %}{{ column_type }}{% endif %}</option>
{% endfor %}
</select>
</label>
{% if page.draft.columns.show_temporal_type() %}
<label>Temporal type
<select name="temporal_type_input">
<option value="">Choose a temporal type</option>
{% for temporal_type in temporal_types %}
<option value="{{ temporal_type }}" {% if page.draft.columns.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.columns.show_gtin_type() %}
<label>GTIN type
<select name="gtin_type_input">
<option value="">Choose a GTIN length</option>
{% for gtin_type in gtin_types %}
<option value="{{ gtin_type }}" {% if page.draft.columns.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.columns.show_decimal_arguments() %}
<label>Precision
<input name="decimal_precision_input" value="{{ page.draft.columns.decimal_precision_input }}"
inputmode="numeric" placeholder="12">
<small>Total digits stored, at least 1.</small>
</label>
<label>Scale
<input name="decimal_scale_input" value="{{ page.draft.columns.decimal_scale_input }}"
inputmode="numeric" placeholder="3">
<small>Digits after the point, no more than the precision.</small>
</label>
{% endif %}
{% if page.draft.columns.show_link_target() %}
<label>Referenced table
<select name="link_table_input">
<option value="">Choose a table</option>
{% if !page.draft.global_relation_tables().is_empty() %}
<optgroup label="Global">
{% for table in page.draft.global_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if !page.draft.user_relation_tables().is_empty() %}
<optgroup label="User-created">
{% for table in page.draft.user_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if !page.draft.system_relation_tables().is_empty() %}
<optgroup label="System-created">
{% for table in page.draft.system_relation_tables() %}
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
{% if page.draft.relation_tables.is_empty() %}<small>No eligible tables exist in this scope.</small>{% endif %}
</label>
{% endif %}
{% if page.draft.columns.show_money_options() %}
<label>Currency
<input name="column_currency_input" value="{{ page.draft.columns.currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
<label>Rounding
<select name="column_rounding_input">
<option value="none" {% if page.draft.columns.rounding_input != "half-up" %}selected{% endif %}>none</option>
<option value="half-up" {% if page.draft.columns.rounding_input == "half-up" %}selected{% endif %}>half-up</option>
</select>
</label>
{% endif %}
{# Neither applies to a definition row: it leaves no column to index, and
nothing to keep a quantity ledger on. #}
{% if !page.draft.columns.pending_is_compound() %}
<label>Indexing
<select name="column_indexing_input">
<option value="no" {% if page.draft.columns.indexing_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.columns.indexing_input == "yes" %}selected{% endif %}>yes</option>
</select>
</label>
<label>Quantity ledger
<select name="column_quantity_ledger_input">
<option value="no" {% if page.draft.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
</select>
<small>{{ page.draft.columns.quantity_ledger_types() }} only.</small>
</label>
{% endif %}
</div>
{% if page.draft.columns.pending_is_compound() %}
<div class="compound-note">
<p><code>{{ page.draft.columns.pending_compound_name() }}</code> is a definition row, not a column of its own. Adding it gives the table:</p>
<ul>
{% for generated in page.draft.columns.pending_generated_columns() %}
{# The currency and rounding are named rather than shown: those two
inputs sit above with no `change` trigger of their own, so their
current values are only known here on the next render. #}
<li><code>{{ generated.name }}</code> <span class="hint">{{ generated.data_type }}{% if generated.inherits_currency %} — kept in the currency and rounding chosen above{% endif %}</span></li>
{% endfor %}
{% if page.draft.columns.pending_compound_name() == "accounting" %}
<li><code>account_id</code> <span class="hint">system column — the account each row is posted to, written as <code>account</code></span></li>
{% endif %}
</ul>
<p class="hint">These names are reserved: no column of your own may use them, and the table may hold only one <code>{{ page.draft.columns.pending_compound_name() }}</code>. They land where this row sits in the column list, and can be moved with it.</p>
</div>
{% endif %}
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>Add column</button>
</section>
<section class="builder-section">
<h2>Columns <span class="count">{{ page.draft.columns.added.len() }}</span></h2>
{% if page.draft.columns.is_empty() %}
<p class="hint">No columns yet. Describe one above and press <em>Add column</em>.</p>
{% else %}
<p class="hint">Columns are created in this order. Use the arrows to move one.</p>
<table class="builder-table">
<thead><tr><th>Order</th><th>Name</th><th>Type</th><th>Indexed</th><th>Options</th><th></th></tr></thead>
<tbody>
{#
Every declared column, each followed by the columns the server will
generate from it. `row.index` is what says which is which: a
generated column has none, because it is not the user's to move,
index or remove — it belongs to the definition row above it.
#}
{% for row in page.column_rows() %}
<tr {% if row.index.is_none() %}class="generated-row"{% endif %}>
<td class="order">
{% if let Some(index) = row.index %}
<button type="button" class="toggle move" aria-label="Move {{ row.name }} up"
{% if row.first %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "move-column-up", "index": "{{ index }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="Move {{ row.name }} down"
{% if row.last %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "move-column-down", "index": "{{ index }}"}'{% endif %}>&darr;</button>
{% else %}
<span class="hint">&#8627;</span>
{% endif %}
</td>
<td><code>{{ row.name }}</code></td>
<td>{{ row.data_type }}</td>
<td>
{# A compound column expands into schema-managed companions, so
there is no column of its own name to index. #}
{% if row.indexable %}
{% if let Some(index) = row.index %}
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-index", "index": "{{ index }}"}'>
{% if row.indexed %}[x]{% else %}[ ]{% endif %}
</button>
{% endif %}
{% else if row.indexed %}
<span class="tag">indexed</span>
{% else %}
<span class="hint">&mdash;</span>
{% endif %}
</td>
<td>{% for tag in row.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
<td>
{% if let Some(index) = row.index %}
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ index }}"}'>Remove</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# The draft itself: one set of fields per column, in order. #}
{% for column in page.draft.columns.added %}
<input type="hidden" name="column_names" value="{{ column.name }}">
<input type="hidden" name="column_types" value="{{ column.data_type }}">
<input type="hidden" name="column_indexed" value="{% if column.indexed %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_quantity_ledger" value="{% if column.quantity_ledger %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_rounding" value="{{ column.money_mode.label() }}">
<input type="hidden" name="column_currencies" value="{{ column.currency }}">
{% endfor %}
</section>
<section class="builder-section">
{% for table in page.draft.relation_tables %}
<input type="hidden" name="relation_tables" value="{{ table }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>Row display columns</h2>
<p class="hint">What identifies a row to users, in the order chosen. Pick <code>id</code> to clear the list.</p>
<ul class="builder-list">
{% for candidate in page.row_display_candidates() %}
<li>
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-display", "index": "{{ candidate.index }}"}'>{{ candidate.mark() }}</button>
<code>{{ candidate.name }}</code>
</li>
{% endfor %}
</ul>
{% for display in page.draft.row_display_columns %}
<input type="hidden" name="row_display_columns" value="{{ display }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>Table definition preview</h2>
<table class="builder-table preview">
<thead><tr><th></th><th>Column</th><th>Type</th><th>Options</th><th>Source</th></tr></thead>
<tbody>
{% for row in page.draft.preview_rows() %}
<tr class="source-{{ row.source }}">
<td class="mark">{{ row.mark }}</td>
<td><code>{{ row.column }}</code></td>
<td>{{ row.data_type }}</td>
<td>{{ row.option }}</td>
<td>{{ row.source }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<div class="form-actions">
<a href="/admin">Cancel</a>
<button type="submit">Create table</button>
</div>