import ui

This commit is contained in:
Priec
2026-08-18 09:51:50 +02:00
parent eb3e22c45e
commit a8877e4418
9 changed files with 309 additions and 107 deletions

View File

@@ -1,21 +1,47 @@
{#
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.
#}
{# Mapping conveniences over rules the server enforces anyway. #}
<script>
(function () {
let selectedSource = "";
/* Source positions the user has picked out of the palette. The order a
batch lands in is the palette's order, not the click order, so this only
has to record membership. */
const selectedSources = new Set();
let selectionAnchor = "";
function chips(workbench) {
return Array.from(workbench.querySelectorAll("[data-source-chip]"));
}
function targets(workbench) {
return Array.from(workbench.querySelectorAll("[data-map-target]"));
}
function sourceSelect(row) {
return row.querySelector("[data-source-select]");
}
function selectedInSourceOrder(workbench) {
return chips(workbench)
.map((chip) => chip.dataset.sourcePosition)
.filter((position) => selectedSources.has(position));
}
/* Redraws everything derived from the selects: no state lives anywhere but
in the selects themselves and the selection set, so any change can just
call this. */
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));
const rows = targets(workbench);
const selects = rows.map(sourceSelect);
// Which destination took each source, so the palette can say where a
// column went and no source can be taken twice.
const takenBy = new Map();
rows.forEach((row, index) => {
if (selects[index].value) takenBy.set(selects[index].value, row.dataset.destinationName);
});
for (const select of selects) {
for (const option of select.options) {
option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value);
option.disabled = option.value !== "" && option.value !== select.value && takenBy.has(option.value);
}
}
@@ -25,60 +51,154 @@
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 sourceChips = chips(workbench);
const order = selectedInSourceOrder(workbench);
sourceChips.forEach((chip) => {
const position = chip.dataset.sourcePosition;
const destination = takenBy.get(position);
const selected = selectedSources.has(position);
chip.classList.toggle("is-mapped", Boolean(destination));
chip.classList.toggle("is-selected", selected);
chip.setAttribute("aria-pressed", String(selected));
chip.querySelector("[data-source-target]").textContent = destination ? "→ " + destination : "";
// The number a selected chip carries is the position it will land in
// when the batch is placed.
chip.querySelector("[data-chip-order]").textContent = selected
? String(order.indexOf(position) + 1)
: "";
});
workbench.classList.toggle("has-selection", selectedSources.size > 0);
const count = workbench.querySelector("[data-selection-count]");
if (count) count.textContent = selectedSources.size + " / " + sourceChips.length;
const hint = workbench.querySelector("[data-selection-hint]");
if (hint) hint.hidden = selectedSources.size === 0;
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;
summary.querySelector("[data-mapped-count]").textContent = takenBy.size;
summary.querySelector("[data-attention-count]").textContent = rows.length - takenBy.size;
}
}
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 = "";
function flash(row) {
row.classList.add("just-mapped");
setTimeout(function () { row.classList.remove("just-mapped"); }, 320);
}
function clearSelection(workbench) {
selectedSources.clear();
selectionAnchor = "";
sync(workbench);
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-select]")) {
sync(event.target.closest("[data-mapping-workbench]"));
function choose(chip, event) {
const workbench = chip.closest("[data-mapping-workbench]");
const position = chip.dataset.sourcePosition;
if (event.shiftKey && selectionAnchor) {
const sourceChips = chips(workbench);
const start = sourceChips.findIndex((item) => item.dataset.sourcePosition === selectionAnchor);
const end = sourceChips.indexOf(chip);
if (!event.ctrlKey && !event.metaKey) selectedSources.clear();
sourceChips.slice(Math.min(start, end), Math.max(start, end) + 1)
.forEach((item) => selectedSources.add(item.dataset.sourcePosition));
} else if (event.ctrlKey || event.metaKey) {
if (selectedSources.has(position)) selectedSources.delete(position);
else selectedSources.add(position);
selectionAnchor = position;
} else {
selectedSources.clear();
selectedSources.add(position);
selectionAnchor = position;
}
sync(workbench);
}
/* The batch move: `positions` fill consecutive destinations starting at
`firstRow`. A source can only be in one place, so wherever it sat before
is cleared first. */
function assign(workbench, positions, firstRow) {
if (!positions.length || !firstRow) return;
const rows = targets(workbench);
const start = rows.indexOf(firstRow);
const usable = positions.slice(0, rows.length - start);
workbench.querySelectorAll("[data-source-select]").forEach((select) => {
if (usable.includes(select.value)) select.value = "";
});
usable.forEach((position, offset) => {
const row = rows[start + offset];
sourceSelect(row).value = position;
flash(row);
});
clearSelection(workbench);
}
/* The fast path for a file already written in the table's column order:
first column to first row, and so on. It replaces the whole mapping
rather than filling the gaps, so what it leaves is exactly what it says. */
function mapInOrder(workbench) {
const positions = chips(workbench).map((chip) => chip.dataset.sourcePosition);
targets(workbench).forEach((row, index) => {
const position = positions[index] ?? "";
sourceSelect(row).value = position;
if (position) flash(row);
});
clearSelection(workbench);
}
function clearMappings(workbench) {
workbench.querySelectorAll("[data-source-select]").forEach((select) => { select.value = ""; });
clearSelection(workbench);
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-select]")) sync(event.target.closest("[data-mapping-workbench]"));
});
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]"));
const inOrder = event.target.closest("[data-map-in-order]");
if (inOrder) { mapInOrder(inOrder.closest("[data-mapping-workbench]")); return; }
const clearAll = event.target.closest("[data-clear-all-mappings]");
if (clearAll) { clearMappings(clearAll.closest("[data-mapping-workbench]")); return; }
const all = event.target.closest("[data-select-all-sources]");
if (all) {
const workbench = all.closest("[data-mapping-workbench]");
chips(workbench).forEach((chip) => selectedSources.add(chip.dataset.sourcePosition));
sync(workbench);
return;
}
const clearSelected = event.target.closest("[data-clear-source-selection]");
if (clearSelected) { clearSelection(clearSelected.closest("[data-mapping-workbench]")); return; }
const chip = event.target.closest("[data-source-chip]");
if (chip) { choose(chip, event); return; }
const clear = event.target.closest("[data-clear-mapping]");
if (clear) {
clear.closest("[data-map-target]").querySelector("[data-source-select]").value = "";
sourceSelect(clear.closest("[data-map-target]")).value = "";
sync(clear.closest("[data-mapping-workbench]"));
return;
}
// Anywhere else on a destination row places the current selection there,
// except on the row's own select, which is the one-at-a-time control.
const row = event.target.closest("[data-map-target]");
if (row && selectedSource && !event.target.closest("select")) {
assign(row.closest("[data-mapping-workbench]"), selectedSource, row);
if (row && selectedSources.size && !event.target.closest("label")) {
const workbench = row.closest("[data-mapping-workbench]");
assign(workbench, selectedInSourceOrder(workbench), 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);
// Dragging an unselected chip is a one-column move, and does not carry a
// selection the user made elsewhere along with it.
if (!selectedSources.has(chip.dataset.sourcePosition)) {
selectedSources.clear();
selectedSources.add(chip.dataset.sourcePosition);
}
const workbench = chip.closest("[data-mapping-workbench]");
event.dataTransfer.setData("text/plain", selectedInSourceOrder(workbench).join(","));
event.dataTransfer.effectAllowed = "move";
chip.classList.add("is-dragging");
sync(workbench);
});
document.addEventListener("dragover", function (event) {
const row = event.target.closest("[data-map-target]");
@@ -96,14 +216,15 @@
if (!row) return;
event.preventDefault();
row.classList.remove("drop-ready");
assign(row.closest("[data-mapping-workbench]"), event.dataTransfer.getData("text/plain"), row);
assign(row.closest("[data-mapping-workbench]"), event.dataTransfer.getData("text/plain").split(",").filter(Boolean), 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 = "";
selectedSources.clear();
selectionAnchor = "";
sync(event.detail.target.querySelector?.("[data-mapping-workbench]"));
});
sync(document.querySelector("[data-mapping-workbench]"));

View File

@@ -71,51 +71,73 @@
<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>
{#
Two lists, never zipped: the file's columns on the left as a movable
palette, the table's columns on the right as fixed destinations. Selecting
a run of source chips and clicking a destination fills consecutively from
there, which is how a whole batch moves in one gesture.
#}
<section class="mapping-workbench" data-mapping-workbench>
<div class="mapping-toolbar">
<button type="button" class="secondary" data-map-in-order>{{ nav.tr("import-map-in-order") }}</button>
<button type="button" class="secondary" data-clear-all-mappings>{{ nav.tr("import-clear-all-mappings") }}</button>
</div>
<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 }}">
<div class="destination-name">
<code>{{ row.name }}</code>
{% if row.required %}<span class="tag">{{ nav.tr("import-required-short") }}</span>{% endif %}
<div class="mapping-panels">
<section class="source-palette">
<div class="palette-heading">
<h3>{{ nav.tr("import-csv-columns") }}</h3>
<div class="palette-actions">
<button type="button" class="secondary" data-select-all-sources>{{ nav.tr("import-select-all-columns") }}</button>
<button type="button" class="secondary" data-clear-source-selection>{{ nav.tr("import-clear-selection") }}</button>
<span class="selection-count" data-selection-count></span>
</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>
<p class="hint">{{ nav.tr("import-csv-columns-hint") }}</p>
<p class="selection-hint" data-selection-hint hidden>{{ nav.tr("import-selection-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-pressed="false"
aria-label="{{ nav.tr_args("import-drag-source", [("column", source.label.clone())]) }}">
<span class="chip-order" data-chip-order aria-hidden="true"></span>
<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>
<span class="chip-target" data-source-target></span>
</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 data-destination-name="{{ row.name }}">
<input type="hidden" name="destination" value="{{ row.key }}">
<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>
</section>
</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>