import page error propagation

This commit is contained in:
Priec
2026-08-17 23:56:21 +02:00
parent 9b2b429dc6
commit b71ed4f42f
16 changed files with 496 additions and 163 deletions

View File

@@ -1,34 +1,111 @@
{#
Two things the mapping table does in the browser, both of them conveniences
over rules the server enforces anyway.
Mapping 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 () {
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.
let selectedSource = "";
function sync(workbench) {
if (!workbench) return;
const rows = Array.from(workbench.querySelectorAll("[data-map-target]"));
const selects = rows.map((row) => row.querySelector("[data-source-select]"));
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 ?? "";
}
rows.forEach((row, index) => {
const option = selects[index].selectedOptions[0];
row.classList.toggle("is-mapped", Boolean(selects[index].value));
row.querySelector("[data-example-cell]").textContent = option?.dataset.example ?? "";
row.querySelector("[data-clear-mapping]").disabled = !selects[index].value;
});
workbench.querySelectorAll("[data-source-chip]").forEach((chip) => {
chip.classList.toggle("is-mapped", taken.has(chip.dataset.sourcePosition));
chip.classList.toggle("is-selected", chip.dataset.sourcePosition === selectedSource);
});
const summary = document.querySelector("[data-mapping-summary]");
if (summary) {
const mapped = taken.size;
summary.querySelector("[data-mapped-count]").textContent = mapped;
summary.querySelector("[data-attention-count]").textContent = rows.length - mapped;
}
}
function assign(workbench, position, row) {
if (!position || !row) return;
workbench.querySelectorAll("[data-source-select]").forEach((select) => {
if (select.value === position) select.value = "";
});
row.querySelector("[data-source-select]").value = position;
selectedSource = "";
row.classList.add("just-mapped");
setTimeout(function () { row.classList.remove("just-mapped"); }, 320);
sync(workbench);
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-select]")) sync();
if (event.target.matches("[data-source-select]")) {
sync(event.target.closest("[data-mapping-workbench]"));
}
});
document.body.addEventListener("htmx:afterSwap", sync);
sync();
document.addEventListener("click", function (event) {
const chip = event.target.closest("[data-source-chip]");
if (chip) {
selectedSource = selectedSource === chip.dataset.sourcePosition ? "" : chip.dataset.sourcePosition;
sync(chip.closest("[data-mapping-workbench]"));
return;
}
const clear = event.target.closest("[data-clear-mapping]");
if (clear) {
clear.closest("[data-map-target]").querySelector("[data-source-select]").value = "";
sync(clear.closest("[data-mapping-workbench]"));
return;
}
const row = event.target.closest("[data-map-target]");
if (row && selectedSource && !event.target.closest("select")) {
assign(row.closest("[data-mapping-workbench]"), selectedSource, row);
}
});
document.addEventListener("dragstart", function (event) {
const chip = event.target.closest("[data-source-chip]");
if (!chip) return;
event.dataTransfer.setData("text/plain", chip.dataset.sourcePosition);
event.dataTransfer.effectAllowed = "move";
chip.classList.add("is-dragging");
});
document.addEventListener("dragover", function (event) {
const row = event.target.closest("[data-map-target]");
if (!row) return;
event.preventDefault();
row.classList.add("drop-ready");
event.dataTransfer.dropEffect = "move";
});
document.addEventListener("dragleave", function (event) {
const row = event.target.closest("[data-map-target]");
if (row && !row.contains(event.relatedTarget)) row.classList.remove("drop-ready");
});
document.addEventListener("drop", function (event) {
const row = event.target.closest("[data-map-target]");
if (!row) return;
event.preventDefault();
row.classList.remove("drop-ready");
assign(row.closest("[data-mapping-workbench]"), event.dataTransfer.getData("text/plain"), row);
});
document.addEventListener("dragend", function (event) {
event.target.closest("[data-source-chip]")?.classList.remove("is-dragging");
document.querySelectorAll("[data-map-target].drop-ready").forEach((row) => row.classList.remove("drop-ready"));
});
document.body.addEventListener("htmx:afterSwap", function (event) {
selectedSource = "";
sync(event.detail.target.querySelector?.("[data-mapping-workbench]"));
});
sync(document.querySelector("[data-mapping-workbench]"));
})();
</script>

View File

@@ -12,11 +12,11 @@
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.
Import is the form's default action, including an implicit submit with Enter.
Download overrides the action on its own button, so only that explicit click
can return a file.
#}
<form id="import-form" method="post" action="/admin/import/prepared.csv"
<form id="import-form" method="post" action="/admin/import"
hx-target="#import-step" hx-swap="outerHTML" hx-disabled-elt="this">
{% include "pages/import_export/import/step.html" %}
</form>

View File

@@ -38,6 +38,8 @@
<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>
{% 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="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>
@@ -53,40 +55,59 @@
<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-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><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>
{#
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.
#}
<div class="mapping-summary" data-mapping-summary
data-mapped-label="{{ nav.tr("import-summary-mapped-short") }}"
data-attention-label="{{ nav.tr("import-summary-attention-short") }}">
<span><strong data-mapped-count>{{ step.mapped }}</strong> {{ nav.tr("import-summary-mapped-short") }}</span>
<span><strong data-attention-count>{{ step.attention }}</strong> {{ nav.tr("import-summary-attention-short") }}</span>
<span class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</span>
</div>
<div class="mapping-workbench" data-mapping-workbench
data-unmapped-label="{{ nav.tr("import-destination-unmapped") }}">
<section class="source-palette">
<h3>{{ nav.tr("import-csv-columns") }}</h3>
<p class="hint">{{ nav.tr("import-csv-columns-hint") }}</p>
<div class="source-chips">
{% for source in step.sources %}
<button type="button" class="source-chip" draggable="true"
data-source-chip data-source-position="{{ source.position }}"
aria-label="{{ nav.tr_args("import-drag-source", [("column", source.label.clone())]) }}">
<code>{% if source.name.is_empty() %}{{ nav.tr_args("import-source-column", [("position", source.position.to_string())]) }}{% else %}{{ source.name }}{% endif %}</code>
<small>{{ source.example }}</small>
</button>
{% endfor %}
</div>
</section>
<section class="destination-map">
<h3>{{ nav.tr("import-table-columns") }}</h3>
<p class="hint">{{ nav.tr("import-table-columns-hint") }}</p>
<ol class="destination-rows">
{% for row in step.rows %}
<li data-map-target>
<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>
</table>
<div class="destination-name">
<code>{{ row.name }}</code>
{% if row.required %}<span class="tag">{{ nav.tr("import-required-short") }}</span>{% endif %}
</div>
<span class="mapping-arrow" aria-hidden="true"></span>
<label>
<span>{{ nav.tr("import-value-from") }}</span>
<select name="source_position" data-source-select>
<option value="">{{ nav.tr("import-destination-unmapped") }}</option>
{% for source in step.sources %}
<option value="{{ source.position }}" data-example="{{ source.example }}"{% if row.takes(source) %} selected{% endif %}>{{ source.label }}</option>
{% endfor %}
</select>
</label>
<span class="mapped-example" data-example-cell>{{ row.example }}</span>
<button type="button" class="clear-mapping" data-clear-mapping{% if row.chosen.is_empty() %} disabled{% endif %}>{{ nav.tr("import-clear-mapping") }}</button>
</li>
{% endfor %}
</ol>
</section>
</div>
</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>
@@ -150,10 +171,11 @@
</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"
<button type="submit" hx-post="/admin/import" hx-include="closest form"
hx-target="#submission-status" hx-swap="innerHTML">{{ nav.tr("import-import-rows") }}</button>
{# A real submit: the answer is a file, and only this button asks for it. #}
<button type="submit" class="secondary" formaction="/admin/import/prepared.csv"
formnovalidate>{{ nav.tr("import-download-prepared") }}</button>
</div>
{% endmatch %}

View File

@@ -6,8 +6,8 @@
keyboard. With scripting off the dropdown lists every table it was rendered
with, and the server still refuses a table that is not in the scope.
Bound again after every HTMX swap, because "Normalize headers" replaces the
fields — and with them both selects — with fresh nodes.
Bound again after every HTMX step swap, which replaces both selects with
fresh nodes when the user returns to the source step.
#}
<script>
(function () {