import improvements

This commit is contained in:
Priec
2026-08-17 14:33:45 +02:00
parent 69dd0137ab
commit 1b9136f1de
17 changed files with 2162 additions and 905 deletions

View File

@@ -0,0 +1,44 @@
{#
Two things the mapping form does in the browser, both of them conveniences
over rules the server enforces anyway.
Everything is delegated from `document`, because each step swaps the whole
block: a listener bound to a select would be thrown away with it.
#}
<script>
(function () {
// A template is generated from the table, so there is no file to give.
// Hiding the fields is not the check — the server refuses a file import
// with no CSV either way.
function syncSourceFields() {
const mode = document.querySelector("[data-source-mode]:checked");
const fields = document.querySelector("[data-source-fields]");
if (!mode || !fields) return;
fields.hidden = mode.value === "template";
}
// A destination column can be filled from one source position only. The
// mapping step refuses a repeat outright; this stops it being offered, so
// the refusal is not how the user finds out.
function syncDestinations() {
const selects = Array.from(document.querySelectorAll("[data-destination]"));
const taken = new Set(selects.map((select) => select.value).filter(Boolean));
for (const select of selects) {
for (const option of select.options) {
option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value);
}
}
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-mode]")) syncSourceFields();
if (event.target.matches("[data-destination]")) syncDestinations();
});
document.body.addEventListener("htmx:afterSwap", function () {
syncSourceFields();
syncDestinations();
});
syncSourceFields();
syncDestinations();
})();
</script>

View File

@@ -0,0 +1,13 @@
{#
What the first step answered, carried forward.
Steps 2 and 3 replace the fields that hold these, so they re-state them: the
form is the whole state of the preparation, and the destination and the source
text have to survive every swap. The summary line above them is so the user
can still see what they are preparing after the selects are gone.
#}
<p class="hint">{{ nav.tr_args("import-carried", [("scope", page.form.profile_name.clone()), ("table", page.form.table_name.clone())]) }}</p>
<input type="hidden" name="profile_name" value="{{ page.form.profile_name }}">
<input type="hidden" name="table_name" value="{{ page.form.table_name }}">
<input type="hidden" name="source_mode" value="{{ page.form.source_mode }}">
<input type="hidden" name="csv_data" value="{{ page.form.csv_data }}">

View File

@@ -1,40 +0,0 @@
{#
The form's fields, on their own so that "Normalize headers" can hand them
back with the rewritten CSV in the textarea — see
crate::pages::import_export::import::ui::ImportFields. `changed` is `None`
when the page first renders and `Some` once a rewrite has run.
#}
<div id="import-fields">
<div class="form-grid">
<label>{{ nav.tr("import-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("import-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}
<select name="table_names" data-table-select required>
<option value="">{{ nav.tr("import-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.table_names.contains(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>
<label class="check">
<input type="checkbox" name="import_system_columns" value="true"{% if page.form.import_system_columns() %} checked{% endif %}>
{{ nav.tr("import-system-columns") }}
</label>
<small>{{ nav.tr("import-system-columns-hint") }}</small>
{%- if let Some(changed) = changed %}
<p class="hint" role="status">{% if changed %}{{ nav.tr("import-normalized-message") }}{% else %}{{ nav.tr("import-normalized-unchanged") }}{% endif %}</p>
{%- endif %}
</div>

View File

@@ -1,6 +1,5 @@
{# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #}
{% extends "ui/form_page.html" %}
{% import "ui/alert.html" as alert %}
{% block title %}{{ nav.tr("import-title") }}{% endblock %}
{% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %}
@@ -8,22 +7,33 @@
{% block lead %}<p>{{ nav.tr("import-lead") }}</p>{% endblock %}
{% block form %}
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
{% include "pages/import_export/import/fields.html" %}
<div id="submission-status" aria-live="polite">
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
</div>
<div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a>
{# Rewrites the header rows and puts the result back in the textarea. It
imports nothing: the file that gets imported is the one the user can see
and has had the chance to read. #}
<button type="button" hx-post="/admin/import/normalize" hx-include="closest form"
hx-target="#import-fields" hx-swap="outerHTML"
title="{{ nav.tr("import-normalize-hint") }}">{{ nav.tr("import-normalize") }}</button>
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
</div>
{#
One form for the whole preparation. The steps swap the block inside it rather
than the form itself, so the scope, the table and the CSV the user chose stay
where they are while they move back and forth.
Its own `action` is the prepared-CSV download: a file has to come back from a
real browser submit, so the plain submit button is the one that downloads and
every other button carries an `hx-post` of its own.
#}
<form id="import-form" method="post" action="/admin/import/prepared.csv"
hx-target="#import-step" hx-swap="outerHTML" hx-disabled-elt="this">
{% include "pages/import_export/import/step.html" %}
</form>
{% include "pages/import_export/table_picker.html" %}
{% include "pages/import_export/import/behaviour.html" %}
{#
A refusal is not a step. Without this the alert answering "Continue" would be
swapped in place of the step it was refusing, and the user would lose the
mapping they were being told to fix.
#}
<script>
document.addEventListener("htmx:beforeSwap", function (event) {
const form = event.detail.elt.closest && event.detail.elt.closest("#import-form");
const status = document.getElementById("submission-status");
if (!form || !status || event.detail.xhr.status < 400) return;
event.detail.target = status;
event.detail.swapOverride = "innerHTML";
});
</script>
{% endblock %}

View File

@@ -0,0 +1,226 @@
{#
The preparation, one step at a time —
crate::pages::import_export::import::ui::ImportStep.
Every button that moves between steps swaps this whole block, so each step
re-states the answers the next one needs as hidden fields. The page keeps no
server-side draft: what the form carries is the entire state of the
preparation.
#}
<div id="import-step">
{% match page.step %}
{#- ------------------------------------------------------------------
Step 1 — where the data is going, and how the source is laid out.
------------------------------------------------------------------ -#}
{% when Step::Source %}
<div class="form-grid">
<label>{{ nav.tr("import-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("import-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}
<select name="table_name" data-table-select required>
<option value="">{{ nav.tr("import-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.is_table(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
{#
Always asked, never guessed. A data row can hold words that read exactly
like column names, so deciding for the user is how a real row gets eaten
as a header.
#}
<fieldset class="wide source-mode">
<legend>{{ nav.tr("import-source-mode") }}</legend>
<label class="check"><input type="radio" name="source_mode" value="header" data-source-mode
{% if page.form.is_mode("header") %} checked{% endif %}>{{ nav.tr("import-source-header") }}</label>
<label class="check"><input type="radio" name="source_mode" value="data" data-source-mode
{% if page.form.is_mode("data") %} checked{% endif %}>{{ nav.tr("import-source-data") }}</label>
<label class="check"><input type="radio" name="source_mode" value="template" data-source-mode
{% if page.form.is_mode("template") %} checked{% endif %}>{{ nav.tr("import-source-template") }}</label>
<p class="hint">{{ nav.tr("import-source-mode-hint") }}</p>
</fieldset>
<div class="wide" data-source-fields>
<div class="form-grid">
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="12">{{ page.form.csv_data }}</textarea></label>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>
</div>
</div>
<div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a>
<button type="button" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-continue") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 2a — one row per source position, pointed wherever the user says.
------------------------------------------------------------------ -#}
{% when Step::Mapping with (step) %}
{% include "pages/import_export/import/carried.html" %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-mapping-heading", [("table", step.table_name.clone())]) }}</h2>
<p class="hint">{{ nav.tr("import-mapping-hint") }}</p>
<p class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</p>
<table class="builder-table mapping-table">
<thead>
<tr>
<th>{{ nav.tr("import-th-position") }}</th>
{% if step.has_source_names %}<th>{{ nav.tr("import-th-source-name") }}</th>{% endif %}
<th>{{ nav.tr("import-th-example") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th>
</tr>
</thead>
<tbody>
{% for row in step.rows %}
<tr>
<td class="mark">{{ row.position }}</td>
{% if step.has_source_names %}<td class="source-name">{% if let Some(name) = row.source_name %}{{ name }}{% endif %}</td>{% endif %}
<td class="example">{{ row.example }}</td>
<td>
<select name="mapping" data-destination>
<option value="">{{ nav.tr("import-destination-ignore") }}</option>
{% for column in step.columns %}
<option value="{{ column }}"{% if row.targets(column) %} selected{% endif %}>{{ column }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
<button type="button" hx-post="/admin/import/preview" hx-include="closest form">{{ nav.tr("import-to-preview") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 2b — the template generator: pick the columns, arrange them, take
away the header.
------------------------------------------------------------------ -#}
{% when Step::Template with (step) %}
{% include "pages/import_export/import/carried.html" %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-template-heading", [("table", step.table_name.clone())]) }}</h2>
<p class="hint">{{ nav.tr("import-template-hint") }}</p>
<table class="builder-table template-table">
<thead>
<tr>
<th>{{ nav.tr("import-th-order") }}</th>
<th>{{ nav.tr("import-th-include") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th>
</tr>
</thead>
<tbody>
{% for row in step.rows %}
<tr>
<td class="order">
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-up", [("name", row.name.clone())]) }}"
{% if row.first %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "up", "index": "{{ row.index }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-down", [("name", row.name.clone())]) }}"
{% if row.last %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "down", "index": "{{ row.index }}"}'{% endif %}>&darr;</button>
</td>
<td>
{#
Both fields post in document order, so moving a row moves the
generated header with it: `template_order` remembers where the
unticked ones sit, `template_columns` is the header itself.
#}
<input type="hidden" name="template_order" value="{{ row.name }}">
<label class="check"><input type="checkbox" name="template_columns" value="{{ row.name }}"{% if row.chosen %} checked{% endif %}
hx-post="/admin/import/prepare" hx-include="closest form"></label>
</td>
<td><code>{{ row.name }}</code>{% if row.required %} <span class="hint">{{ nav.tr("import-required-column") }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if step.chosen > 0 %}
<p class="hint">{{ nav.tr("import-template-generated") }}</p>
<pre class="sql-preview">{{ step.header }}</pre>
{% else %}
<p class="hint">{{ nav.tr("import-template-nothing-chosen") }}</p>
{% endif %}
<p class="hint">{{ nav.tr("import-template-round-trip") }}</p>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
{# A real submit: the answer is a file. #}
<button type="submit" formaction="/admin/import/template.csv" formnovalidate>{{ nav.tr("import-download-template") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 3 — the prepared import, exactly as it will be sent.
------------------------------------------------------------------ -#}
{% when Step::Preview with (step) %}
{% include "pages/import_export/import/carried.html" %}
{% for value in page.form.mapping %}<input type="hidden" name="mapping" value="{{ value }}">{% endfor %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
<dl class="postable-grid">
<div>
<dt>{{ nav.tr("import-summary-rows") }}</dt>
<dd><strong>{{ step.source_rows }}</strong></dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-columns-used") }}</dt>
<dd>{{ step.columns|join(", ") }}</dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-ignored") }}</dt>
<dd>{% if step.ignored.is_empty() %}<span class="hint"></span>{% else %}{{ step.ignored|join(", ") }}{% endif %}</dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-omitted") }}</dt>
<dd>{% if step.omitted.is_empty() %}<span class="hint"></span>{% else %}{{ step.omitted|join(", ") }}{% endif %}</dd>
</div>
</dl>
{% if !step.missing_required.is_empty() %}
<p class="error">{{ nav.tr_args("import-summary-missing-required", [("columns", step.missing_required.join(", "))]) }}</p>
{% endif %}
<div class="table-wrap">
<table class="builder-table preview">
<thead><tr>{% for column in step.columns %}<th>{{ column }}</th>{% endfor %}</tr></thead>
<tbody>
{% for row in step.rows %}
<tr>{% for value in row %}<td>{{ value }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if step.hidden_rows > 0 %}
<p class="hint">{{ nav.tr_args("import-preview-more-rows", [("rows", step.hidden_rows.to_string())]) }}</p>
{% endif %}
<details class="rename-generated">
<summary>{{ nav.tr("import-prepared-csv") }}</summary>
<p class="hint">{{ nav.tr("import-prepared-csv-hint") }}</p>
<pre class="sql-preview">{{ step.csv }}</pre>
</details>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-back-to-mapping") }}</button>
{# A real submit: the answer is a file. #}
<button type="submit" formnovalidate>{{ nav.tr("import-download-prepared") }}</button>
<button type="button" hx-post="/admin/import" hx-include="closest form"
hx-target="#submission-status" hx-swap="innerHTML">{{ nav.tr("import-import-rows") }}</button>
</div>
{% endmatch %}
<div id="submission-status" aria-live="polite"></div>
</div>