import page error propagation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user