diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 442a47b5..e5ced89d 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -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 } diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index f17e6911..eb472856 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -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 } diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 7fbdd0de..03b02384 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -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 } diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index e2cdac54..9a323a29 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -109,14 +109,6 @@ pub(crate) async fn prepare_step( .collect::>(); let has_answers = !form.destination.is_empty(); - let positions_by_name = source - .header - .iter() - .enumerate() - .fold(HashMap::<&str, Vec>::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, diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index 7b7e3867..1330ad52 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -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, /// Columns parsed from the CSV's first row, available to connect to a /// destination by dragging or selecting. pub sources: Vec, + /// 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, pub source_rows: usize, pub mapped: usize, pub attention: usize, diff --git a/web/src/pages/import_export/import/ui.rs b/web/src/pages/import_export/import/ui.rs index f60c9ee7..b4c527d2 100644 --- a/web/src/pages/import_export/import/ui.rs +++ b/web/src/pages/import_export/import/ui.rs @@ -223,6 +223,31 @@ mod tests { assert!(html.contains(">Not mapped — leave empty"), "{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] diff --git a/web/static/app.css b/web/static/app.css index 9a7e9999..6d7c20d7 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; } diff --git a/web/templates/pages/import_export/import/behaviour.html b/web/templates/pages/import_export/import/behaviour.html index de4c5fd9..ba68915b 100644 --- a/web/templates/pages/import_export/import/behaviour.html +++ b/web/templates/pages/import_export/import/behaviour.html @@ -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. #}