import improvements2

This commit is contained in:
Priec
2026-08-17 15:20:31 +02:00
parent 1b9136f1de
commit 71807f9a6d
15 changed files with 944 additions and 1292 deletions

View File

@@ -1,5 +1,5 @@
{#
Two things the mapping form does in the browser, both of them conveniences
Two things the mapping table 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
@@ -7,38 +7,28 @@
#}
<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]"));
function sync() {
const selects = Array.from(document.querySelectorAll("[data-source-select]"));
// A source column can fill one destination only. The mapping refuses a
// repeat outright; this stops it being offered, so the refusal is not
// how the user finds out.
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);
}
// The example beside the row is the first data row's value at the
// chosen position, so the row shows what it is actually going to
// import rather than only which column it named.
const cell = select.closest("tr")?.querySelector("[data-example-cell]");
if (cell) cell.textContent = select.selectedOptions[0]?.dataset.example ?? "";
}
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-mode]")) syncSourceFields();
if (event.target.matches("[data-destination]")) syncDestinations();
if (event.target.matches("[data-source-select]")) sync();
});
document.body.addEventListener("htmx:afterSwap", function () {
syncSourceFields();
syncDestinations();
});
syncSourceFields();
syncDestinations();
document.body.addEventListener("htmx:afterSwap", sync);
sync();
})();
</script>

View File

@@ -2,12 +2,11 @@
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.
form is the whole state of the preparation, and the destination and the file
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

@@ -11,7 +11,7 @@
{% match page.step %}
{#- ------------------------------------------------------------------
Step 1 — where the data is going, and how the source is laid out.
Step 1 — where the data is going, and the file it comes from.
------------------------------------------------------------------ -#}
{% when Step::Source %}
<div class="form-grid">
@@ -31,33 +31,12 @@
{%- 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>
<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 class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a>
@@ -65,7 +44,9 @@
</div>
{#- ------------------------------------------------------------------
Step 2a — one row per source position, pointed wherever the user says.
Step 2 — one row per destination column, asking where its value comes
from. Read this way round, a destination cannot be filled twice: it
appears exactly once.
------------------------------------------------------------------ -#}
{% when Step::Mapping with (step) %}
{% include "pages/import_export/import/carried.html" %}
@@ -76,26 +57,32 @@
<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>
<th>{{ nav.tr("import-th-required") }}</th>
<th>{{ nav.tr("import-th-source") }}</th>
<th>{{ nav.tr("import-th-example") }}</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><code>{{ row.name }}</code></td>
<td>{% if row.required %}{{ nav.tr("common-yes") }}{% else %}<span class="hint">{{ nav.tr("common-no") }}</span>{% endif %}</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>
{#
The destination travels as its stable identity, so a rename
between here and Import moves the mapping with the column
instead of leaving it pointing at a name.
#}
<input type="hidden" name="destination" value="{{ row.key }}">
<select name="source_position" data-source-select>
<option value="">{{ nav.tr("import-source-none") }}</option>
{% for option in step.sources %}
<option value="{{ option.position }}" data-example="{{ option.example }}"{% if row.takes(option) %} selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</td>
<td class="example" data-example-cell>{{ row.example }}</td>
</tr>
{% endfor %}
</tbody>
@@ -106,69 +93,17 @@
<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 %}
{#
The mapping travels with the preview, so Import and Download prepare the
identical file from the identical answers.
#}
{% for key in page.form.destination %}<input type="hidden" name="destination" value="{{ key }}">{% endfor %}
{% for position in page.form.source_position %}<input type="hidden" name="source_position" value="{{ position }}">{% endfor %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
<dl class="postable-grid">