import ui
This commit is contained in:
@@ -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]"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user