import ui
This commit is contained in:
@@ -610,12 +610,17 @@ import-success-message = Vloženo { $inserted ->
|
||||
|
||||
# --- Krok 2: mapování ------------------------------------------------------
|
||||
import-mapping-heading = Přiřaďte sloupce CSV do tabulky { $table }
|
||||
import-mapping-hint = První řádek CSV byl načten jako názvy jeho sloupců. Připojte pouze sloupce CSV, které chcete importovat; nepřipojené sloupce tabulky zůstanou prázdné.
|
||||
import-mapping-hint = Nic se nenamapuje, dokud to nevyberete. Namapujte celý soubor podle pořadí, nebo vyberte sloupce CSV a pusťte je na sloupec tabulky, kterým mají začít.
|
||||
import-mapping-rows = Soubor obsahuje { $rows } datových řádků.
|
||||
import-summary-mapped-short = namapováno
|
||||
import-summary-attention-short = sloupců tabulky bez mapování
|
||||
import-csv-columns = Nalezené sloupce CSV
|
||||
import-csv-columns-hint = Přetáhněte sloupec na sloupec tabulky nebo na něj klikněte a potom klikněte na řádek tabulky. Malý text je první datová hodnota.
|
||||
import-csv-columns-hint = Klikněte na sloupec, Ctrl/Cmd-kliknutím přidejte další nebo Shift-kliknutím vyberte rozsah. Malý text je první datová hodnota.
|
||||
import-selection-hint = Kliknutím na sloupec tabulky nebo přetažením na něj umístíte vybrané sloupce postupně od něj.
|
||||
import-select-all-columns = Vybrat všechny
|
||||
import-clear-selection = Zrušit výběr
|
||||
import-map-in-order = Namapovat podle pořadí
|
||||
import-clear-all-mappings = Zrušit všechna mapování
|
||||
import-table-columns = Původní sloupce tabulky
|
||||
import-table-columns-hint = Zobrazen je každý sloupec tabulky. Pokud jej záměrně nechcete importovat, ponechte řádek jako „Nenamapováno“.
|
||||
import-drag-source = Namapovat sloupec CSV { $column }
|
||||
|
||||
@@ -598,12 +598,17 @@ import-success-message = Inserted { $inserted ->
|
||||
|
||||
# --- Step 2: mapping -------------------------------------------------------
|
||||
import-mapping-heading = Map CSV columns into { $table }
|
||||
import-mapping-hint = The CSV's first row was read as its column names. Connect only the CSV columns you want to import; table columns left without a connection stay empty.
|
||||
import-mapping-hint = Nothing is mapped until you choose it. Map the whole file in order, or select CSV columns and drop them on the table column they should start at.
|
||||
import-mapping-rows = The file holds { $rows } data rows.
|
||||
import-summary-mapped-short = mapped
|
||||
import-summary-attention-short = table columns not mapped
|
||||
import-csv-columns = CSV columns found
|
||||
import-csv-columns-hint = Drag one onto a table column, or click it and then click a table row. The small text is the first data value.
|
||||
import-csv-columns-hint = Click a column, Ctrl/Cmd-click to add more, or Shift-click for a range. The small text is the first data value.
|
||||
import-selection-hint = Click or drag onto a table column to place the selected columns there, one after another.
|
||||
import-select-all-columns = Select all
|
||||
import-clear-selection = Clear selection
|
||||
import-map-in-order = Map in order
|
||||
import-clear-all-mappings = Clear all mappings
|
||||
import-table-columns = Original table columns
|
||||
import-table-columns-hint = Every table column is shown. Leave a row as “Not mapped” when you intentionally do not want to import it.
|
||||
import-drag-source = Map CSV column { $column }
|
||||
|
||||
@@ -608,12 +608,17 @@ import-success-message = Vložený { $inserted ->
|
||||
|
||||
# --- Krok 2: mapovanie -----------------------------------------------------
|
||||
import-mapping-heading = Priraďte stĺpce CSV do tabuľky { $table }
|
||||
import-mapping-hint = Prvý riadok CSV bol načítaný ako názvy jeho stĺpcov. Pripojte iba stĺpce CSV, ktoré chcete importovať; nepripojené stĺpce tabuľky zostanú prázdne.
|
||||
import-mapping-hint = Nič sa nenamapuje, kým to nevyberiete. Namapujte celý súbor podľa poradia, alebo vyberte stĺpce CSV a pustite ich na stĺpec tabuľky, ktorým majú začať.
|
||||
import-mapping-rows = Súbor obsahuje { $rows } riadkov údajov.
|
||||
import-summary-mapped-short = namapovaných
|
||||
import-summary-attention-short = stĺpcov tabuľky bez mapovania
|
||||
import-csv-columns = Nájdené stĺpce CSV
|
||||
import-csv-columns-hint = Potiahnite stĺpec na stĺpec tabuľky alebo naň kliknite a potom kliknite na riadok tabuľky. Malý text je prvá hodnota údajov.
|
||||
import-csv-columns-hint = Kliknite na stĺpec, Ctrl/Cmd-kliknutím pridajte ďalšie alebo Shift-kliknutím vyberte rozsah. Malý text je prvá hodnota údajov.
|
||||
import-selection-hint = Kliknutím na stĺpec tabuľky alebo potiahnutím naň umiestnite vybrané stĺpce postupne od neho.
|
||||
import-select-all-columns = Vybrať všetky
|
||||
import-clear-selection = Zrušiť výber
|
||||
import-map-in-order = Namapovať podľa poradia
|
||||
import-clear-all-mappings = Zrušiť všetky mapovania
|
||||
import-table-columns = Pôvodné stĺpce tabuľky
|
||||
import-table-columns-hint = Zobrazený je každý stĺpec tabuľky. Ak ho zámerne nechcete importovať, ponechajte riadok ako „Nenamapované“.
|
||||
import-drag-source = Namapovať stĺpec CSV { $column }
|
||||
|
||||
@@ -109,14 +109,6 @@ pub(crate) async fn prepare_step(
|
||||
.collect::<HashMap<_, _>>();
|
||||
let has_answers = !form.destination.is_empty();
|
||||
|
||||
let positions_by_name = source
|
||||
.header
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(HashMap::<&str, Vec<usize>>::new(), |mut positions, (index, name)| {
|
||||
positions.entry(name.as_str()).or_default().push(index);
|
||||
positions
|
||||
});
|
||||
let rows = destination
|
||||
.columns
|
||||
.iter()
|
||||
@@ -125,10 +117,7 @@ pub(crate) async fn prepare_step(
|
||||
let chosen_index = if has_answers {
|
||||
answered.get(&key).copied()
|
||||
} else {
|
||||
positions_by_name
|
||||
.get(column.name.as_str())
|
||||
.filter(|positions| positions.len() == 1)
|
||||
.and_then(|positions| positions.first().copied())
|
||||
None
|
||||
};
|
||||
MappingRow {
|
||||
key,
|
||||
@@ -148,8 +137,8 @@ pub(crate) async fn prepare_step(
|
||||
|
||||
let step = Step::Mapping(MappingStep {
|
||||
table_name: destination.table_name.clone(),
|
||||
rows,
|
||||
sources: source_options(locale, &source),
|
||||
rows,
|
||||
source_rows: source.rows.len(),
|
||||
mapped,
|
||||
attention,
|
||||
|
||||
@@ -169,11 +169,15 @@ pub(crate) enum Step {
|
||||
|
||||
pub(crate) struct MappingStep {
|
||||
pub table_name: String,
|
||||
/// One fixed row per destination table column.
|
||||
pub rows: Vec<MappingRow>,
|
||||
/// Columns parsed from the CSV's first row, available to connect to a
|
||||
/// destination by dragging or selecting.
|
||||
pub sources: Vec<SourceOption>,
|
||||
/// One fixed row per destination table column.
|
||||
///
|
||||
/// Kept as its own list rather than zipped with `sources`: the two sides
|
||||
/// have no row-for-row relationship, and showing them as if they did makes
|
||||
/// mere adjacency look like a mapping.
|
||||
pub rows: Vec<MappingRow>,
|
||||
pub source_rows: usize,
|
||||
pub mapped: usize,
|
||||
pub attention: usize,
|
||||
|
||||
@@ -223,6 +223,31 @@ mod tests {
|
||||
assert!(html.contains(">Not mapped — leave empty</option>"), "{html}");
|
||||
}
|
||||
|
||||
/// The two sides are two lists, not one zipped table: a source chip carries
|
||||
/// only its own position, and a destination row only its own key. Nothing
|
||||
/// in the markup pairs the two by their displayed order.
|
||||
#[test]
|
||||
fn the_mapping_step_keeps_the_two_sides_apart() {
|
||||
let html = render_step(&page(mapping()));
|
||||
|
||||
assert_eq!(html.matches(r#"class="source-chip""#).count(), 2);
|
||||
assert_eq!(html.matches("data-map-target").count(), 2);
|
||||
// A destination names itself, so the palette can say where a column went.
|
||||
assert!(html.contains(r#"data-destination-name="a""#), "{html}");
|
||||
}
|
||||
|
||||
/// Moving a batch is the point of the step, so both ways of doing it are on
|
||||
/// the page: the whole file at once, and a selection placed by hand.
|
||||
#[test]
|
||||
fn the_mapping_step_offers_the_batch_actions() {
|
||||
let html = render_step(&page(mapping()));
|
||||
|
||||
assert!(html.contains("data-map-in-order"), "{html}");
|
||||
assert!(html.contains("data-clear-all-mappings"), "{html}");
|
||||
assert!(html.contains("data-select-all-sources"), "{html}");
|
||||
assert!(html.contains(r#"draggable="true""#), "{html}");
|
||||
}
|
||||
|
||||
/// The destination travels as its stable identity, never as its name, so a
|
||||
/// rename between the mapping and the import moves with the column.
|
||||
#[test]
|
||||
|
||||
@@ -357,27 +357,52 @@
|
||||
dragging a source chip onto it. */
|
||||
.mapping-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 14px 0; padding: 10px 12px; border-radius: 8px; background: #f5f7fa; }
|
||||
.mapping-summary strong { color: #24324a; font-variant-numeric: tabular-nums; }
|
||||
.mapping-workbench { display: grid; grid-template-columns: minmax(230px, .75fr) minmax(480px, 1.6fr); align-items: start; gap: 14px; }
|
||||
.mapping-workbench h3 { margin: 0; color: #24324a; font-size: 14px; }
|
||||
.mapping-workbench h3 + .hint { margin: 4px 0 12px; font-size: 12px; }
|
||||
|
||||
/* The batch actions stay reachable while a long destination list scrolls. */
|
||||
.mapping-toolbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 12px; padding: 8px; border: 1px solid #d8e1ec; border-radius: 8px; background: rgb(255 255 255 / 96%); box-shadow: 0 3px 12px rgb(31 43 58 / 7%); }
|
||||
.mapping-toolbar button.secondary { margin: 0; padding: 6px 10px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; font-size: 12px; cursor: pointer; }
|
||||
.mapping-toolbar button.secondary:hover { background: #e9edf4; }
|
||||
|
||||
.mapping-panels { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(460px, 1.5fr); align-items: start; gap: 14px; }
|
||||
.source-palette, .destination-map { padding: 14px; border: 1px solid #dfe5ed; border-radius: 9px; background: #fafbfc; }
|
||||
/* The palette follows the destination list down, so a column near the bottom
|
||||
of a wide table still has something to be dropped from. */
|
||||
.source-palette { position: sticky; top: 56px; max-height: calc(100vh - 76px); overflow: auto; }
|
||||
.palette-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px; }
|
||||
.palette-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.palette-actions button.secondary { margin: 0; padding: 4px 8px; border: 1px solid #cfd8e5; border-radius: 6px; color: #46566d; background: white; font-size: 11px; cursor: pointer; }
|
||||
.selection-count { color: #6d798a; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.selection-hint { margin: 0 0 10px; padding: 7px 9px; border-radius: 6px; color: #1d4ed8; background: #eef4ff; font-size: 12px; }
|
||||
|
||||
.source-chips { display: grid; gap: 7px; }
|
||||
.source-chip { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; border: 1px solid #cfd9e6; border-radius: 7px; color: #33415c; background: white; text-align: left; cursor: grab; transition: border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease; }
|
||||
.source-chip:hover, .source-chip.is-selected { border-color: #78a1df; box-shadow: 0 3px 10px rgb(31 43 58 / 9%); }
|
||||
.source-chip.is-selected { outline: 3px solid rgb(37 99 235 / 12%); }
|
||||
.source-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 2px 8px; width: 100%; padding: 9px 10px; border: 1px solid #cfd9e6; border-radius: 7px; color: #33415c; background: white; text-align: left; cursor: grab; transition: border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease; }
|
||||
.source-chip:hover { border-color: #78a1df; box-shadow: 0 3px 10px rgb(31 43 58 / 9%); }
|
||||
.source-chip.is-mapped { border-color: #a8cfb3; background: #f4fbf6; }
|
||||
.source-chip.is-selected { border-color: #78a1df; background: #f3f7ff; outline: 3px solid rgb(37 99 235 / 12%); }
|
||||
.source-chip.is-dragging { opacity: .55; cursor: grabbing; }
|
||||
.source-chip code { overflow: hidden; text-overflow: ellipsis; }
|
||||
.source-chip small { max-width: 100px; overflow: hidden; color: #778396; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* The rank a selected column will take when the batch is placed. Hidden
|
||||
entirely while nothing is selected, so every chip shifts together. */
|
||||
.chip-order { grid-row: 1 / span 2; display: grid; width: 19px; height: 19px; place-items: center; border-radius: 50%; color: white; background: #2563eb; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.mapping-workbench:not(.has-selection) .chip-order, .chip-order:empty { display: none; }
|
||||
/* Where this column ended up, read off the destination it was given. */
|
||||
.chip-target { grid-column: 2 / -1; overflow: hidden; color: #287443; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip-target:empty { display: none; }
|
||||
|
||||
.destination-rows { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
|
||||
.destination-rows li { display: grid; grid-template-columns: minmax(100px, .7fr) 22px minmax(210px, 1.3fr) minmax(70px, .65fr) auto; align-items: center; gap: 8px; min-height: 62px; padding: 8px 9px; border: 1px dashed #cdd6e2; border-radius: 7px; background: white; transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; }
|
||||
.destination-rows li.is-mapped { border-style: solid; border-color: #b9cce8; }
|
||||
/* With a selection live, every row is somewhere the batch can be dropped. */
|
||||
.mapping-workbench.has-selection .destination-rows li { border-color: #9bbcf0; cursor: copy; }
|
||||
.destination-rows li.drop-ready { border-style: solid; border-color: #2563eb; background: #edf4ff; box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); }
|
||||
.destination-rows li.just-mapped { background: #eef8f1; }
|
||||
.destination-name { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; }
|
||||
.destination-name code { overflow: hidden; color: #24324a; text-overflow: ellipsis; }
|
||||
.mapping-arrow { color: #9aa5b4; text-align: center; }
|
||||
.destination-rows label { display: grid; gap: 3px; color: #778396; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.destination-rows label { display: grid; gap: 3px; color: #778396; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; cursor: auto; }
|
||||
.destination-rows select { min-width: 0; color: #24324a; font-size: 13px; letter-spacing: normal; text-transform: none; }
|
||||
.mapped-example { min-width: 0; overflow: hidden; color: #667385; font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.clear-mapping { padding: 6px 8px; border: 1px solid #d6dde7; border-radius: 6px; color: #59677a; background: white; font-size: 11px; cursor: pointer; }
|
||||
@@ -385,7 +410,8 @@
|
||||
.clear-mapping:disabled { color: #bdc4ce; background: #f7f8fa; cursor: default; }
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.mapping-workbench { grid-template-columns: 1fr; }
|
||||
.mapping-panels { grid-template-columns: 1fr; }
|
||||
.source-palette { position: static; max-height: none; }
|
||||
.source-chips { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
.destination-rows li { grid-template-columns: minmax(90px, .7fr) 18px minmax(180px, 1.3fr) auto; }
|
||||
.mapped-example { display: none; }
|
||||
|
||||
@@ -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,18 +71,39 @@
|
||||
<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") }}">
|
||||
{#
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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 }}"
|
||||
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>
|
||||
@@ -93,7 +114,7 @@
|
||||
<p class="hint">{{ nav.tr("import-table-columns-hint") }}</p>
|
||||
<ol class="destination-rows">
|
||||
{% for row in step.rows %}
|
||||
<li data-map-target>
|
||||
<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>
|
||||
@@ -116,6 +137,7 @@
|
||||
</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