import page error propagation
This commit is contained in:
@@ -4,6 +4,8 @@ pub const ACCOUNT_PATH_METADATA_KEY: &str = "komp-ac-account-path-bin";
|
|||||||
pub const ACCOUNT_PREFIX_METADATA_KEY: &str = "komp-ac-account-prefix-bin";
|
pub const ACCOUNT_PREFIX_METADATA_KEY: &str = "komp-ac-account-prefix-bin";
|
||||||
pub const ACCOUNT_CURRENCY_METADATA_KEY: &str = "komp-ac-account-currency";
|
pub const ACCOUNT_CURRENCY_METADATA_KEY: &str = "komp-ac-account-currency";
|
||||||
pub const ACCOUNT_FIELD_METADATA_KEY: &str = "komp-ac-account-field-bin";
|
pub const ACCOUNT_FIELD_METADATA_KEY: &str = "komp-ac-account-field-bin";
|
||||||
|
pub const BULK_FAILED_ROW_INDEX_METADATA_KEY: &str = "komp-ac-bulk-failed-row-index";
|
||||||
|
pub const BULK_INSERTED_ROWS_METADATA_KEY: &str = "komp-ac-bulk-inserted-rows";
|
||||||
pub const COMPUTED_VALUE_MISMATCH_REASON: &str = "computed-value-mismatch";
|
pub const COMPUTED_VALUE_MISMATCH_REASON: &str = "computed-value-mismatch";
|
||||||
pub const ACCOUNT_NOT_FOUND_REASON: &str = "account-not-found";
|
pub const ACCOUNT_NOT_FOUND_REASON: &str = "account-not-found";
|
||||||
pub const ROW_STALE_REASON: &str = "row-stale";
|
pub const ROW_STALE_REASON: &str = "row-stale";
|
||||||
|
|||||||
2
server
2
server
Submodule server updated: 4fa445ccf8...d8b4bf2eeb
@@ -580,11 +580,16 @@ import-target-tables = Cílová tabulka
|
|||||||
import-choose-table = Vyberte tabulku
|
import-choose-table = Vyberte tabulku
|
||||||
import-csv-file = Soubor CSV
|
import-csv-file = Soubor CSV
|
||||||
import-csv-data = Data CSV
|
import-csv-data = Data CSV
|
||||||
import-csv-format-hint = První řádek je vlastní záhlaví souboru a každý další řádek jsou data. Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "hl","he","s"
|
import-csv-format-hint = První řádek se obvykle použije jako názvy sloupců souboru. Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "hl","he","s"
|
||||||
import-continue = Pokračovat
|
import-continue = Pokračovat
|
||||||
import-carried = Import do tabulky { $table } v rozsahu { $scope }.
|
import-carried = Import do tabulky { $table } v rozsahu { $scope }.
|
||||||
import-error-title = CSV se nepodařilo importovat
|
import-error-title = CSV se nepodařilo importovat
|
||||||
import-import-rows = Importovat
|
import-import-rows = Importovat
|
||||||
|
import-failure-message = Před zastavením importu se importovalo { $inserted } z { $source_rows } řádků. Selhal řádek CSV { $row }.
|
||||||
|
Backend: { $error }
|
||||||
|
Již importované řádky zůstávají v tabulce. Aby nevznikly duplicity, neimportujte znovu celý soubor: nejprve tyto řádky odstraňte nebo importujte pouze chybný a zbývající řádky.
|
||||||
|
import-failure-no-rows-message = Nic se neimportovalo. Selhal řádek CSV { $row }.
|
||||||
|
Backend: { $error }
|
||||||
import-success-title = Import dokončen
|
import-success-title = Import dokončen
|
||||||
import-success-message = Vloženo { $inserted ->
|
import-success-message = Vloženo { $inserted ->
|
||||||
[one] { $inserted } řádek
|
[one] { $inserted } řádek
|
||||||
@@ -597,14 +602,32 @@ import-success-message = Vloženo { $inserted ->
|
|||||||
}.
|
}.
|
||||||
|
|
||||||
# --- Krok 2: mapování ------------------------------------------------------
|
# --- Krok 2: mapování ------------------------------------------------------
|
||||||
import-mapping-heading = Odkud bere hodnotu každý sloupec tabulky { $table }?
|
import-mapping-heading = Přiřaďte sloupce CSV do tabulky { $table }
|
||||||
import-mapping-hint = Každý sloupec tabulky je uveden jednou, takže žádný z nich nelze naplnit dvakrát. Hodnoty se berou podle pozice: název zdrojového sloupce se zobrazuje proto, abyste jej rozeznali, a o ničem nerozhoduje. Sloupec ponechaný na Neimportovat se nezapíše a ostatní sloupce souboru se ignorují.
|
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-rows = Soubor obsahuje { $rows } datových řádků.
|
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-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 }
|
||||||
|
import-destination-unmapped = Nenamapováno — ponechat prázdné
|
||||||
|
import-value-from = Bere hodnoty z
|
||||||
|
import-clear-mapping = Zrušit
|
||||||
import-th-destination = Sloupec tabulky
|
import-th-destination = Sloupec tabulky
|
||||||
import-th-required = Povinný
|
import-th-required = Povinný
|
||||||
import-th-source = Bere hodnotu z
|
import-th-source = Bere hodnotu z
|
||||||
import-th-example = Ukázka
|
import-th-example = Ukázka
|
||||||
import-source-none = Neimportovat
|
import-th-status = Stav
|
||||||
|
import-source-none = Přeskočit tento sloupec souboru
|
||||||
|
import-source-column = Sloupec { $position }
|
||||||
|
import-required-short = povinný
|
||||||
|
import-status-mapped = Namapováno
|
||||||
|
import-status-duplicate = Stejný název je použit dvakrát
|
||||||
|
import-status-unmapped = Nenamapováno
|
||||||
|
import-status-near = Podobá se na
|
||||||
|
import-status-near-match = Podobá se na { $column }
|
||||||
import-source-option = { $name } — pozice { $position }
|
import-source-option = { $name } — pozice { $position }
|
||||||
import-source-unnamed = pozice { $position }
|
import-source-unnamed = pozice { $position }
|
||||||
import-back-to-source = Zpět na soubor
|
import-back-to-source = Zpět na soubor
|
||||||
|
|||||||
@@ -570,11 +570,16 @@ import-target-tables = Target table
|
|||||||
import-choose-table = Choose a table
|
import-choose-table = Choose a table
|
||||||
import-csv-file = CSV file
|
import-csv-file = CSV file
|
||||||
import-csv-data = CSV data
|
import-csv-data = CSV data
|
||||||
import-csv-format-hint = The first row is the file's own header, and every row after it is data. Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "hl","he","s"
|
import-csv-format-hint = The first row is normally used as the file's column names. Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "hl","he","s"
|
||||||
import-continue = Continue
|
import-continue = Continue
|
||||||
import-carried = Importing into { $table } in { $scope }.
|
import-carried = Importing into { $table } in { $scope }.
|
||||||
import-error-title = Could not import CSV
|
import-error-title = Could not import CSV
|
||||||
import-import-rows = Import
|
import-import-rows = Import
|
||||||
|
import-failure-message = Imported { $inserted } of { $source_rows } rows before the import stopped. CSV row { $row } failed.
|
||||||
|
Backend: { $error }
|
||||||
|
Rows already imported remain in the table. To avoid duplicates, do not retry the whole file: remove those rows first, or import only the failed and remaining rows.
|
||||||
|
import-failure-no-rows-message = Nothing was imported. CSV row { $row } failed.
|
||||||
|
Backend: { $error }
|
||||||
import-success-title = Import complete
|
import-success-title = Import complete
|
||||||
import-success-message = Inserted { $inserted ->
|
import-success-message = Inserted { $inserted ->
|
||||||
[one] { $inserted } row
|
[one] { $inserted } row
|
||||||
@@ -585,14 +590,32 @@ import-success-message = Inserted { $inserted ->
|
|||||||
}.
|
}.
|
||||||
|
|
||||||
# --- Step 2: mapping -------------------------------------------------------
|
# --- Step 2: mapping -------------------------------------------------------
|
||||||
import-mapping-heading = Where does each column of { $table } take its value from?
|
import-mapping-heading = Map CSV columns into { $table }
|
||||||
import-mapping-hint = Every column of the table is listed once, so none of them can be filled twice. Values are taken by position: a source column's name is shown so you can recognise it, and decides nothing. A column left on Do not import is not written, and the file's other columns are ignored.
|
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-rows = The file holds { $rows } data rows.
|
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-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 }
|
||||||
|
import-destination-unmapped = Not mapped — leave empty
|
||||||
|
import-value-from = Gets values from
|
||||||
|
import-clear-mapping = Clear
|
||||||
import-th-destination = Table column
|
import-th-destination = Table column
|
||||||
import-th-required = Required
|
import-th-required = Required
|
||||||
import-th-source = Takes its value from
|
import-th-source = Takes its value from
|
||||||
import-th-example = Example
|
import-th-example = Example
|
||||||
import-source-none = Do not import
|
import-th-status = Status
|
||||||
|
import-source-none = Skip this file column
|
||||||
|
import-source-column = Column { $position }
|
||||||
|
import-required-short = required
|
||||||
|
import-status-mapped = Mapped
|
||||||
|
import-status-duplicate = Same name appears twice
|
||||||
|
import-status-unmapped = Not mapped
|
||||||
|
import-status-near = Looks like
|
||||||
|
import-status-near-match = Looks like { $column }
|
||||||
import-source-option = { $name } — position { $position }
|
import-source-option = { $name } — position { $position }
|
||||||
import-source-unnamed = position { $position }
|
import-source-unnamed = position { $position }
|
||||||
import-back-to-source = Back to the file
|
import-back-to-source = Back to the file
|
||||||
|
|||||||
@@ -580,11 +580,16 @@ import-target-tables = Cieľová tabuľka
|
|||||||
import-choose-table = Vyberte tabuľku
|
import-choose-table = Vyberte tabuľku
|
||||||
import-csv-file = Súbor CSV
|
import-csv-file = Súbor CSV
|
||||||
import-csv-data = Údaje CSV
|
import-csv-data = Údaje CSV
|
||||||
import-csv-format-hint = Prvý riadok je vlastná hlavička súboru a každý ďalší riadok sú údaje. Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "hl","he","s"
|
import-csv-format-hint = Prvý riadok sa zvyčajne použije ako názvy stĺpcov súboru. Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "hl","he","s"
|
||||||
import-continue = Pokračovať
|
import-continue = Pokračovať
|
||||||
import-carried = Import do tabuľky { $table } v rozsahu { $scope }.
|
import-carried = Import do tabuľky { $table } v rozsahu { $scope }.
|
||||||
import-error-title = CSV sa nepodarilo importovať
|
import-error-title = CSV sa nepodarilo importovať
|
||||||
import-import-rows = Importovať
|
import-import-rows = Importovať
|
||||||
|
import-failure-message = Pred zastavením importu sa importovalo { $inserted } z { $source_rows } riadkov. Zlyhal riadok CSV { $row }.
|
||||||
|
Backend: { $error }
|
||||||
|
Už importované riadky zostávajú v tabuľke. Aby nevznikli duplicity, neimportujte znova celý súbor: najprv tieto riadky odstráňte alebo importujte iba chybný a zostávajúce riadky.
|
||||||
|
import-failure-no-rows-message = Nič sa neimportovalo. Zlyhal riadok CSV { $row }.
|
||||||
|
Backend: { $error }
|
||||||
import-success-title = Import dokončený
|
import-success-title = Import dokončený
|
||||||
import-success-message = Vložený { $inserted ->
|
import-success-message = Vložený { $inserted ->
|
||||||
[one] { $inserted } riadok
|
[one] { $inserted } riadok
|
||||||
@@ -595,14 +600,32 @@ import-success-message = Vložený { $inserted ->
|
|||||||
}.
|
}.
|
||||||
|
|
||||||
# --- Krok 2: mapovanie -----------------------------------------------------
|
# --- Krok 2: mapovanie -----------------------------------------------------
|
||||||
import-mapping-heading = Odkiaľ berie hodnotu každý stĺpec tabuľky { $table }?
|
import-mapping-heading = Priraďte stĺpce CSV do tabuľky { $table }
|
||||||
import-mapping-hint = Každý stĺpec tabuľky je uvedený raz, takže žiadny z nich sa nedá naplniť dvakrát. Hodnoty sa berú podľa pozície: názov zdrojového stĺpca sa zobrazuje preto, aby ste ho rozoznali, a o ničom nerozhoduje. Stĺpec ponechaný na Neimportovať sa nezapíše a ostatné stĺpce súboru sa ignorujú.
|
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-rows = Súbor obsahuje { $rows } riadkov údajov.
|
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-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 }
|
||||||
|
import-destination-unmapped = Nenamapované — ponechať prázdne
|
||||||
|
import-value-from = Berie hodnoty z
|
||||||
|
import-clear-mapping = Zrušiť
|
||||||
import-th-destination = Stĺpec tabuľky
|
import-th-destination = Stĺpec tabuľky
|
||||||
import-th-required = Povinný
|
import-th-required = Povinný
|
||||||
import-th-source = Berie hodnotu z
|
import-th-source = Berie hodnotu z
|
||||||
import-th-example = Ukážka
|
import-th-example = Ukážka
|
||||||
import-source-none = Neimportovať
|
import-th-status = Stav
|
||||||
|
import-source-none = Preskočiť tento stĺpec súboru
|
||||||
|
import-source-column = Stĺpec { $position }
|
||||||
|
import-required-short = povinný
|
||||||
|
import-status-mapped = Namapované
|
||||||
|
import-status-duplicate = Rovnaký názov je použitý dvakrát
|
||||||
|
import-status-unmapped = Nenamapované
|
||||||
|
import-status-near = Podobá sa na
|
||||||
|
import-status-near-match = Podobá sa na { $column }
|
||||||
import-source-option = { $name } — pozícia { $position }
|
import-source-option = { $name } — pozícia { $position }
|
||||||
import-source-unnamed = pozícia { $position }
|
import-source-unnamed = pozícia { $position }
|
||||||
import-back-to-source = Späť na súbor
|
import-back-to-source = Späť na súbor
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ mod i18n;
|
|||||||
#[path = "../../common/src/system_column.rs"]
|
#[path = "../../common/src/system_column.rs"]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod system_column;
|
mod system_column;
|
||||||
|
#[path = "../../common/src/grpc_error.rs"]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod grpc_error;
|
||||||
mod analytics {
|
mod analytics {
|
||||||
include!(concat!(
|
include!(concat!(
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
//! The columns an import may write into, and how the form names one.
|
//! The columns an import may write into, and how the form names one.
|
||||||
//!
|
//!
|
||||||
//! The mapping is read destination-first: every writable column of the table
|
//! The review is source-first: every file position gets one row and chooses a
|
||||||
//! gets one row, and the row asks where its value comes from. That is what
|
//! destination by stable identity. Duplicate destination choices are prevented
|
||||||
//! makes "one destination filled twice" impossible to express rather than
|
//! in the browser and refused again by the Rust mapping validation.
|
||||||
//! merely refused — each destination appears exactly once, by construction.
|
|
||||||
|
|
||||||
use crate::definitions::table_structure::{TableColumn, TableStructureResponse};
|
use crate::definitions::table_structure::{TableColumn, TableStructureResponse};
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ use axum::{
|
|||||||
http::{HeaderMap, HeaderValue, header},
|
http::{HeaderMap, HeaderValue, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
// The mapping posts `destination` and `source_position` once per destination
|
// The review posts a destination and selected CSV position once per table row;
|
||||||
// row, and `axum::Form` (serde_urlencoded) cannot decode repeated keys into a
|
// only axum-extra's HTML form decoder preserves repeated keys as vectors.
|
||||||
// `Vec`.
|
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -79,8 +78,8 @@ pub(crate) async fn source_step(
|
|||||||
render_step(state, &headers, form, Step::Source).await
|
render_step(state, &headers, form, Step::Source).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/import/prepare — the mapping: one row per writable destination
|
/// POST /admin/import/prepare — parsed CSV columns mapped onto fixed table
|
||||||
/// column, asking where its value comes from.
|
/// columns.
|
||||||
pub(crate) async fn prepare_step(
|
pub(crate) async fn prepare_step(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -99,42 +98,59 @@ pub(crate) async fn prepare_step(
|
|||||||
Err(message) => return reject(&headers, message),
|
Err(message) => return reject(&headers, message),
|
||||||
};
|
};
|
||||||
|
|
||||||
// What the user has already answered, if they are coming back from the
|
// Answers are keyed by stable destination id, so a rename between review
|
||||||
// preview. Keyed by the destination's identity rather than by row order, so
|
// and import does not retarget one.
|
||||||
// a table that gained or lost a column keeps the answers for the rest.
|
|
||||||
let answered = form
|
let answered = form
|
||||||
.rows()
|
.rows()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(key, position)| Some((key.to_string(), position?)))
|
.filter_map(|(key, position)| Some((key.to_string(), position?)))
|
||||||
.collect::<HashMap<_, _>>();
|
.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()
|
||||||
|
.map(|column| {
|
||||||
|
let key = column.key.encode();
|
||||||
|
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())
|
||||||
|
};
|
||||||
|
MappingRow {
|
||||||
|
key,
|
||||||
|
name: column.name.clone(),
|
||||||
|
required: column.required,
|
||||||
|
chosen: chosen_index
|
||||||
|
.map(|index| (index + 1).to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
example: chosen_index
|
||||||
|
.map(|index| source.example(index).to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mapped = rows.iter().filter(|row| !row.chosen.is_empty()).count();
|
||||||
|
let attention = rows.len().saturating_sub(mapped);
|
||||||
|
|
||||||
let step = Step::Mapping(MappingStep {
|
let step = Step::Mapping(MappingStep {
|
||||||
table_name: destination.table_name.clone(),
|
table_name: destination.table_name.clone(),
|
||||||
rows: destination
|
rows,
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.map(|column| {
|
|
||||||
let key = column.key.encode();
|
|
||||||
// Nothing starts out mapped. An exact name match would be a
|
|
||||||
// reasonable guess, but a guess is what this page exists to
|
|
||||||
// avoid — every destination is the user's to answer.
|
|
||||||
let chosen = answered
|
|
||||||
.get(&key)
|
|
||||||
.copied()
|
|
||||||
.filter(|index| *index < source.width());
|
|
||||||
MappingRow {
|
|
||||||
key,
|
|
||||||
name: column.name.clone(),
|
|
||||||
required: column.required,
|
|
||||||
example: chosen
|
|
||||||
.map(|index| source.example(index).to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
chosen: chosen.map(|index| (index + 1).to_string()).unwrap_or_default(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
sources: source_options(locale, &source),
|
sources: source_options(locale, &source),
|
||||||
source_rows: source.rows.len(),
|
source_rows: source.rows.len(),
|
||||||
|
mapped,
|
||||||
|
attention,
|
||||||
});
|
});
|
||||||
render_step(state, &headers, form, step).await
|
render_step(state, &headers, form, step).await
|
||||||
}
|
}
|
||||||
@@ -224,7 +240,7 @@ pub(crate) async fn import_csv(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut inserted = 0usize;
|
let mut inserted = 0usize;
|
||||||
for chunk in converted.chunks(1_000) {
|
for (chunk_index, chunk) in converted.chunks(1_000).enumerate() {
|
||||||
let request = PostTableDataBulkRequest {
|
let request = PostTableDataBulkRequest {
|
||||||
profile_name: profile_name.clone(),
|
profile_name: profile_name.clone(),
|
||||||
table_name: destination.table_name.clone(),
|
table_name: destination.table_name.clone(),
|
||||||
@@ -239,7 +255,15 @@ pub(crate) async fn import_csv(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => response.into_inner(),
|
Ok(response) => response.into_inner(),
|
||||||
Err(error) => return grpc_error(&headers, &error),
|
Err(error) => {
|
||||||
|
return import_error(
|
||||||
|
&headers,
|
||||||
|
&error,
|
||||||
|
inserted,
|
||||||
|
chunk_index * 1_000,
|
||||||
|
prepared.row_count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
inserted += response
|
inserted += response
|
||||||
.responses
|
.responses
|
||||||
@@ -361,7 +385,8 @@ async fn prepared(
|
|||||||
) -> Result<(Destination, Source, Prepared), Response> {
|
) -> Result<(Destination, Source, Prepared), Response> {
|
||||||
let locale = Locale::from_headers(headers);
|
let locale = Locale::from_headers(headers);
|
||||||
let destination = destination(state, headers, form).await?;
|
let destination = destination(state, headers, form).await?;
|
||||||
let source = read_source(locale, &form.csv_data).map_err(|message| reject(headers, message))?;
|
let source = read_source(locale, &form.csv_data)
|
||||||
|
.map_err(|message| reject(headers, message))?;
|
||||||
|
|
||||||
// Every posted destination has to still exist and still be writable. A key
|
// Every posted destination has to still exist and still be writable. A key
|
||||||
// that resolves to nothing is refused rather than skipped: skipping it
|
// that resolves to nothing is refused rather than skipped: skipping it
|
||||||
@@ -396,6 +421,7 @@ fn source_options(locale: Locale, source: &Source) -> Vec<SourceOption> {
|
|||||||
let name = source.name(index).trim();
|
let name = source.name(index).trim();
|
||||||
SourceOption {
|
SourceOption {
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
|
name: name.to_string(),
|
||||||
label: if name.is_empty() {
|
label: if name.is_empty() {
|
||||||
tr!(locale, "import-source-unnamed", "position" => position)
|
tr!(locale, "import-source-unnamed", "position" => position)
|
||||||
} else {
|
} else {
|
||||||
@@ -469,6 +495,51 @@ fn grpc_error(headers: &HeaderMap, error: &tonic::Status) -> Response {
|
|||||||
.into_response(Locale::from_headers(headers), ui::render_error)
|
.into_response(Locale::from_headers(headers), ui::render_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn import_error(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
error: &tonic::Status,
|
||||||
|
inserted_before_chunk: usize,
|
||||||
|
chunk_start: usize,
|
||||||
|
prepared_rows: usize,
|
||||||
|
) -> Response {
|
||||||
|
let Some((failed_row_index, inserted_in_chunk)) = bulk_failure(error) else {
|
||||||
|
return grpc_error(headers, error);
|
||||||
|
};
|
||||||
|
let locale = Locale::from_headers(headers);
|
||||||
|
let failure = crate::ui::FormError::from_status(error);
|
||||||
|
if matches!(failure, crate::ui::FormError::Unauthenticated) {
|
||||||
|
return failure.into_response(locale, ui::render_error);
|
||||||
|
}
|
||||||
|
let status = failure.status_code();
|
||||||
|
let message = ui::render_import_failure(
|
||||||
|
locale,
|
||||||
|
inserted_before_chunk + inserted_in_chunk,
|
||||||
|
prepared_rows,
|
||||||
|
// One header row precedes the data, and CSV rows are one-based.
|
||||||
|
chunk_start + failed_row_index + 2,
|
||||||
|
failure.message(),
|
||||||
|
);
|
||||||
|
(status, Html(message)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_failure(error: &tonic::Status) -> Option<(usize, usize)> {
|
||||||
|
let failed_row_index = error
|
||||||
|
.metadata()
|
||||||
|
.get(crate::grpc_error::BULK_FAILED_ROW_INDEX_METADATA_KEY)?
|
||||||
|
.to_str()
|
||||||
|
.ok()?
|
||||||
|
.parse()
|
||||||
|
.ok()?;
|
||||||
|
let inserted_rows = error
|
||||||
|
.metadata()
|
||||||
|
.get(crate::grpc_error::BULK_INSERTED_ROWS_METADATA_KEY)?
|
||||||
|
.to_str()
|
||||||
|
.ok()?
|
||||||
|
.parse()
|
||||||
|
.ok()?;
|
||||||
|
Some((failed_row_index, inserted_rows))
|
||||||
|
}
|
||||||
|
|
||||||
fn load_error(headers: &HeaderMap, error: LoadError) -> Response {
|
fn load_error(headers: &HeaderMap, error: LoadError) -> Response {
|
||||||
let locale = Locale::from_headers(headers);
|
let locale = Locale::from_headers(headers);
|
||||||
error
|
error
|
||||||
@@ -502,10 +573,8 @@ mod tests {
|
|||||||
let options = source_options(Locale::default(), &source());
|
let options = source_options(Locale::default(), &source());
|
||||||
|
|
||||||
assert_eq!(options.len(), 5);
|
assert_eq!(options.len(), 5);
|
||||||
assert_eq!(options[2].position, 3);
|
|
||||||
assert!(options[2].label.contains("hl"), "{}", options[2].label);
|
assert!(options[2].label.contains("hl"), "{}", options[2].label);
|
||||||
assert!(options[2].label.contains('3'), "{}", options[2].label);
|
assert!(options[2].label.contains('3'), "{}", options[2].label);
|
||||||
assert_eq!(options[2].example, "value-a");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file that left a column unnamed still has that column, and it is still
|
/// A file that left a column unnamed still has that column, and it is still
|
||||||
@@ -515,6 +584,5 @@ mod tests {
|
|||||||
let options = source_options(Locale::default(), &source());
|
let options = source_options(Locale::default(), &source());
|
||||||
|
|
||||||
assert!(options[3].label.contains('4'), "{}", options[3].label);
|
assert!(options[3].label.contains('4'), "{}", options[3].label);
|
||||||
assert_eq!(options[3].example, "value-b");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
//! Turning the uploaded file plus the user's mapping into the one CSV the
|
//! Turning the uploaded file plus the user's mapping into the one CSV the
|
||||||
//! import understands.
|
//! import understands.
|
||||||
//!
|
//!
|
||||||
//! There is one shape of input: a header row, then data rows. The header is
|
//! The first row names the CSV columns and every later row is data. The names
|
||||||
//! read so the user can tell the file's columns apart, and is then done with —
|
//! are displayed as draggable sources; what decides where values land is the
|
||||||
//! it is never imported, and it never decides anything. What decides is the
|
//! mapping: destination column `a` takes source position 3.
|
||||||
//! mapping: destination column `a` takes source position 3, because the user
|
|
||||||
//! said so.
|
|
||||||
//!
|
//!
|
||||||
//! Positions, not names, all the way through. A file may perfectly well have
|
//! Positions, not names, all the way through. A file may perfectly well have
|
||||||
//! two columns called `name`; the page shows them as `name — position 1` and
|
//! two columns called `name`; the page shows them as `name — position 1` and
|
||||||
@@ -60,7 +58,6 @@ impl Source {
|
|||||||
/// the same width.
|
/// the same width.
|
||||||
pub(crate) fn read_source(locale: Locale, csv: &str) -> Result<Source, String> {
|
pub(crate) fn read_source(locale: Locale, csv: &str) -> Result<Source, String> {
|
||||||
let mut rows = parse_csv(locale, csv)?;
|
let mut rows = parse_csv(locale, csv)?;
|
||||||
// One row is a header with nothing under it, which is not an import.
|
|
||||||
if rows.len() < 2 {
|
if rows.len() < 2 {
|
||||||
return Err(tr!(locale, "import-err-no-data-rows"));
|
return Err(tr!(locale, "import-err-no-data-rows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,15 @@ pub(crate) struct ImportForm {
|
|||||||
/// The uploaded file, header row and all.
|
/// The uploaded file, header row and all.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub csv_data: String,
|
pub csv_data: String,
|
||||||
/// One entry per writable destination column, in the order the mapping step
|
/// One entry per table-column row: the destination's stable identity. See
|
||||||
/// renders them: the column's stable identity, not its name. See
|
|
||||||
/// [`DestinationKey`](super::destination::DestinationKey).
|
/// [`DestinationKey`](super::destination::DestinationKey).
|
||||||
///
|
///
|
||||||
/// Posted once per row, which only `axum_extra`'s `Form` decodes into a
|
/// Posted once per row, which only `axum_extra`'s `Form` decodes into a
|
||||||
/// `Vec`.
|
/// `Vec`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub destination: Vec<String>,
|
pub destination: Vec<String>,
|
||||||
/// The source position chosen for the destination at the same index, from
|
/// The selected CSV position for the destination at the same index, from
|
||||||
/// 1; empty means "do not import". The two vectors are filled in document
|
/// 1; empty means intentionally not mapped.
|
||||||
/// order, so they line up row for row.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub source_position: Vec<String>,
|
pub source_position: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -65,6 +63,7 @@ impl ImportForm {
|
|||||||
self.destination
|
self.destination
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
.filter(|(_, key)| !key.trim().is_empty())
|
||||||
.map(|(index, key)| {
|
.map(|(index, key)| {
|
||||||
let position = self
|
let position = self
|
||||||
.source_position
|
.source_position
|
||||||
@@ -90,32 +89,27 @@ pub(crate) enum Step {
|
|||||||
|
|
||||||
pub(crate) struct MappingStep {
|
pub(crate) struct MappingStep {
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
/// One row per writable destination column. A destination cannot be filled
|
/// One fixed row per destination table column.
|
||||||
/// twice because it appears exactly once.
|
|
||||||
pub rows: Vec<MappingRow>,
|
pub rows: Vec<MappingRow>,
|
||||||
/// The file's columns, as they are offered in every picker.
|
/// Columns parsed from the CSV's first row, available to connect to a
|
||||||
|
/// destination by dragging or selecting.
|
||||||
pub sources: Vec<SourceOption>,
|
pub sources: Vec<SourceOption>,
|
||||||
pub source_rows: usize,
|
pub source_rows: usize,
|
||||||
|
pub mapped: usize,
|
||||||
|
pub attention: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One destination column, asking where its value comes from.
|
/// One destination table column and the source position connected to it.
|
||||||
pub(crate) struct MappingRow {
|
pub(crate) struct MappingRow {
|
||||||
/// The stable identity, for the hidden field.
|
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// The name the table shows, and the name the prepared header will use.
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
/// The chosen source position as the form carries it — one-based, empty for
|
/// One-based source position, empty when intentionally not mapped.
|
||||||
/// "do not import".
|
|
||||||
pub chosen: String,
|
pub chosen: String,
|
||||||
/// The first data row's value at the chosen position, so the row shows what
|
|
||||||
/// it is actually going to import.
|
|
||||||
pub example: String,
|
pub example: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MappingRow {
|
impl MappingRow {
|
||||||
/// Whether this row takes `option`'s position, for re-rendering the picker
|
|
||||||
/// with the user's own answer selected.
|
|
||||||
pub(crate) fn takes(&self, option: &SourceOption) -> bool {
|
pub(crate) fn takes(&self, option: &SourceOption) -> bool {
|
||||||
self.chosen == option.position.to_string()
|
self.chosen == option.position.to_string()
|
||||||
}
|
}
|
||||||
@@ -123,8 +117,8 @@ impl MappingRow {
|
|||||||
|
|
||||||
/// One column of the uploaded file, as the pickers offer it.
|
/// One column of the uploaded file, as the pickers offer it.
|
||||||
pub(crate) struct SourceOption {
|
pub(crate) struct SourceOption {
|
||||||
/// One-based, which is how the page counts and how the form posts.
|
|
||||||
pub position: usize,
|
pub position: usize,
|
||||||
|
pub name: String,
|
||||||
/// `hl — position 3`, or just the position when the file left the name
|
/// `hl — position 3`, or just the position when the file left the name
|
||||||
/// blank. Built in Rust so it is translated once.
|
/// blank. Built in Rust so it is translated once.
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -243,4 +237,15 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(form.rows(), vec![("id:1", Some(1)), ("id:2", None)]);
|
assert_eq!(form.rows(), vec![("id:1", Some(1)), ("id:2", None)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ignored_source_row_has_no_destination_assignment() {
|
||||||
|
let form = ImportForm {
|
||||||
|
destination: vec![String::new(), "id:2".to_string()],
|
||||||
|
source_position: vec!["1".to_string(), "2".to_string()],
|
||||||
|
..ImportForm::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(form.rows(), vec![("id:2", Some(1))]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,32 @@ pub(crate) fn render_error(locale: Locale, message: &str) -> String {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_import_failure(
|
||||||
|
locale: Locale,
|
||||||
|
inserted: usize,
|
||||||
|
prepared_rows: usize,
|
||||||
|
csv_row: usize,
|
||||||
|
backend_message: &str,
|
||||||
|
) -> String {
|
||||||
|
let message_key = if inserted == 0 {
|
||||||
|
"import-failure-no-rows-message"
|
||||||
|
} else {
|
||||||
|
"import-failure-message"
|
||||||
|
};
|
||||||
|
render(&Alert::error(
|
||||||
|
locale,
|
||||||
|
&tr!(locale, "import-error-title"),
|
||||||
|
&tr!(
|
||||||
|
locale,
|
||||||
|
message_key,
|
||||||
|
"inserted" => inserted as i64,
|
||||||
|
"source_rows" => prepared_rows as i64,
|
||||||
|
"row" => csv_row as i64,
|
||||||
|
"error" => backend_message.to_string(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn render_success(
|
pub(crate) fn render_success(
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
inserted: usize,
|
inserted: usize,
|
||||||
@@ -73,9 +99,7 @@ mod tests {
|
|||||||
use crate::auth::AuthorizationSnapshot;
|
use crate::auth::AuthorizationSnapshot;
|
||||||
use crate::pages::import_export::common::loader::{Catalog, Profile};
|
use crate::pages::import_export::common::loader::{Catalog, Profile};
|
||||||
|
|
||||||
use super::super::state::{
|
use super::super::state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption};
|
||||||
ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn strings(values: &[&str]) -> Vec<String> {
|
fn strings(values: &[&str]) -> Vec<String> {
|
||||||
values.iter().map(|value| value.to_string()).collect()
|
values.iter().map(|value| value.to_string()).collect()
|
||||||
@@ -101,7 +125,7 @@ mod tests {
|
|||||||
table_name: "customers".to_string(),
|
table_name: "customers".to_string(),
|
||||||
csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
|
csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
|
||||||
destination: strings(&["id:42", "id:57"]),
|
destination: strings(&["id:42", "id:57"]),
|
||||||
source_position: strings(&["1", ""]),
|
source_position: strings(&["1", "2"]),
|
||||||
},
|
},
|
||||||
step,
|
step,
|
||||||
}
|
}
|
||||||
@@ -115,60 +139,64 @@ mod tests {
|
|||||||
key: "id:42".to_string(),
|
key: "id:42".to_string(),
|
||||||
name: "a".to_string(),
|
name: "a".to_string(),
|
||||||
required: true,
|
required: true,
|
||||||
chosen: "1".to_string(),
|
|
||||||
example: "value-a".to_string(),
|
example: "value-a".to_string(),
|
||||||
|
chosen: "1".to_string(),
|
||||||
},
|
},
|
||||||
MappingRow {
|
MappingRow {
|
||||||
key: "id:57".to_string(),
|
key: "id:57".to_string(),
|
||||||
name: "b".to_string(),
|
name: "b".to_string(),
|
||||||
required: false,
|
required: false,
|
||||||
chosen: String::new(),
|
|
||||||
example: String::new(),
|
example: String::new(),
|
||||||
|
chosen: String::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sources: vec![
|
sources: vec![
|
||||||
SourceOption {
|
SourceOption {
|
||||||
position: 1,
|
position: 1,
|
||||||
label: "hl \u{2014} position 1".to_string(),
|
name: "hl".to_string(),
|
||||||
|
label: "hl — position 1".to_string(),
|
||||||
example: "value-a".to_string(),
|
example: "value-a".to_string(),
|
||||||
},
|
},
|
||||||
SourceOption {
|
SourceOption {
|
||||||
position: 2,
|
position: 2,
|
||||||
label: "he \u{2014} position 2".to_string(),
|
name: "he".to_string(),
|
||||||
|
label: "he — position 2".to_string(),
|
||||||
example: "value-b".to_string(),
|
example: "value-b".to_string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
source_rows: 1,
|
source_rows: 1,
|
||||||
|
mapped: 1,
|
||||||
|
attention: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first step asks for a destination and a file, and nothing else —
|
/// The first step stays focused on the destination and file. The first-row
|
||||||
/// there is one shape of input, so there is nothing to choose about it.
|
/// question comes after parsing, where the page can show the real values.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_first_step_asks_for_a_table_and_a_file() {
|
fn the_first_step_asks_for_a_table_and_a_file() {
|
||||||
let html = render_page(&page(Step::Source));
|
let html = render_page(&page(Step::Source));
|
||||||
|
|
||||||
|
assert!(html.contains(r#"action="/admin/import""#), "{html}");
|
||||||
assert!(html.contains(r#"name="table_name""#), "{html}");
|
assert!(html.contains(r#"name="table_name""#), "{html}");
|
||||||
assert!(html.contains(r#"name="csv_data""#), "{html}");
|
assert!(html.contains(r#"name="csv_data""#), "{html}");
|
||||||
assert!(!html.contains("source_mode"), "{html}");
|
assert!(!html.contains(r#"name="header_mode""#), "{html}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mapping is read destination-first: one row per column of the table,
|
/// The table columns stay fixed while parsed CSV columns can be connected
|
||||||
/// each asking where its value comes from. A destination cannot be filled
|
/// to them or deliberately left unused.
|
||||||
/// twice because it appears exactly once.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_mapping_step_asks_every_destination_column_once() {
|
fn the_mapping_step_reviews_every_source_column_once() {
|
||||||
let html = render_step(&page(mapping()));
|
let html = render_step(&page(mapping()));
|
||||||
|
|
||||||
assert_eq!(html.matches(r#"name="source_position""#).count(), 2);
|
assert_eq!(html.matches(r#"name="source_position""#).count(), 2);
|
||||||
assert_eq!(html.matches(r#"name="destination""#).count(), 2);
|
assert_eq!(html.matches(r#"name="destination""#).count(), 2);
|
||||||
assert!(html.contains("<code>a</code>"), "{html}");
|
assert!(html.contains("CSV columns found"), "{html}");
|
||||||
assert!(html.contains("<code>b</code>"), "{html}");
|
assert!(html.contains("Original table columns"), "{html}");
|
||||||
// The file's columns are offered by name and position together.
|
assert!(html.contains(r#"data-source-position="1""#), "{html}");
|
||||||
assert!(html.contains("hl \u{2014} position 1"), "{html}");
|
assert!(html.contains("value-a"), "{html}");
|
||||||
|
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
|
||||||
assert!(html.contains(r#"<option value="1" data-example="value-a" selected>"#), "{html}");
|
assert!(html.contains(r#"<option value="1" data-example="value-a" selected>"#), "{html}");
|
||||||
// Nothing is mapped unless the user said so.
|
assert!(html.contains(">Not mapped — leave empty</option>"), "{html}");
|
||||||
assert!(html.contains(">Do not import</option>"), "{html}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The destination travels as its stable identity, never as its name, so a
|
/// The destination travels as its stable identity, never as its name, so a
|
||||||
@@ -202,10 +230,38 @@ mod tests {
|
|||||||
assert!(html.contains("aa \u{2014} position 6"), "{html}");
|
assert!(html.contains("aa \u{2014} position 6"), "{html}");
|
||||||
assert!(html.contains("number"), "{html}");
|
assert!(html.contains("number"), "{html}");
|
||||||
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
|
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
|
||||||
|
assert!(
|
||||||
|
html.contains(r#"formaction="/admin/import/prepared.csv""#),
|
||||||
|
"{html}"
|
||||||
|
);
|
||||||
assert!(html.contains(""a","b","c""), "{html}");
|
assert!(html.contains(""a","b","c""), "{html}");
|
||||||
// The mapping travels with it, so the download and the import prepare
|
// The mapping travels with it, so the download and the import prepare
|
||||||
// the identical file.
|
// the identical file.
|
||||||
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
|
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
|
||||||
assert!(html.contains(r#"name="source_position" value="1""#), "{html}");
|
assert!(html.contains(r#"name="source_position" value="1""#), "{html}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_import_failure_explains_partial_progress_and_the_backend_error() {
|
||||||
|
let html = render_import_failure(
|
||||||
|
Locale::English,
|
||||||
|
599,
|
||||||
|
1_200,
|
||||||
|
601,
|
||||||
|
"Internal server error (reference: example-id)",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(html.contains("Imported 599 of 1200 rows"), "{html}");
|
||||||
|
assert!(html.contains("CSV row 601 failed"), "{html}");
|
||||||
|
assert!(html.contains("reference: example-id"), "{html}");
|
||||||
|
assert!(html.contains("Rows already imported remain"), "{html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_row_failure_does_not_claim_that_rows_remain() {
|
||||||
|
let html = render_import_failure(Locale::English, 0, 20, 2, "Invalid value");
|
||||||
|
|
||||||
|
assert!(html.contains("Nothing was imported"), "{html}");
|
||||||
|
assert!(!html.contains("already imported remain"), "{html}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,13 +352,44 @@
|
|||||||
|
|
||||||
/* ---------- Import preparation (pages/import_export/import) ---------- */
|
/* ---------- Import preparation (pages/import_export/import) ---------- */
|
||||||
|
|
||||||
/* A mapping row is read left to right as a sentence: the column `a` takes its
|
/* CSV columns are a movable palette; table columns stay fixed as the import
|
||||||
value from "hl — position 3", which on the first row holds "value-a". The
|
destinations. A select in every row is the keyboard/touch equivalent of
|
||||||
example is what people actually recognise their data by, so it gets the
|
dragging a source chip onto it. */
|
||||||
room, and it is the last thing on the line because it is the confirmation
|
.mapping-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 14px 0; padding: 10px 12px; border-radius: 8px; background: #f5f7fa; }
|
||||||
rather than the choice. */
|
.mapping-summary strong { color: #24324a; font-variant-numeric: tabular-nums; }
|
||||||
.mapping-table select { width: 100%; min-width: 220px; }
|
.mapping-workbench { display: grid; grid-template-columns: minmax(230px, .75fr) minmax(480px, 1.6fr); align-items: start; gap: 14px; }
|
||||||
.mapping-table .example { max-width: 320px; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #33415c; }
|
.mapping-workbench h3 { margin: 0; color: #24324a; font-size: 14px; }
|
||||||
|
.mapping-workbench h3 + .hint { margin: 4px 0 12px; font-size: 12px; }
|
||||||
|
.source-palette, .destination-map { padding: 14px; border: 1px solid #dfe5ed; border-radius: 9px; background: #fafbfc; }
|
||||||
|
.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.is-mapped { border-color: #a8cfb3; background: #f4fbf6; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.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 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; }
|
||||||
|
.clear-mapping:hover:not(:disabled) { color: #a12b2b; background: #fdf3f3; }
|
||||||
|
.clear-mapping:disabled { color: #bdc4ce; background: #f7f8fa; cursor: default; }
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.mapping-workbench { grid-template-columns: 1fr; }
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|
||||||
/* A secondary action in a form's action row — "back a step". The row's own
|
/* A secondary action in a form's action row — "back a step". The row's own
|
||||||
rule paints every button as the primary one, so this has to say otherwise
|
rule paints every button as the primary one, so this has to say otherwise
|
||||||
@@ -380,6 +411,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
.destination-rows li { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.destination-rows .mapping-arrow { display: none; }
|
||||||
|
.destination-rows label { grid-column: 1 / -1; }
|
||||||
|
.destination-rows .clear-mapping { grid-column: 2; grid-row: 1; }
|
||||||
.alias-list-head { display: none; }
|
.alias-list-head { display: none; }
|
||||||
.alias-row { grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1.3fr); }
|
.alias-row { grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1.3fr); }
|
||||||
.alias-row > button { grid-column: 3; width: 100%; }
|
.alias-row > button { grid-column: 3; width: 100%; }
|
||||||
|
|||||||
@@ -1,34 +1,111 @@
|
|||||||
{#
|
{#
|
||||||
Two things the mapping table does in the browser, both of them conveniences
|
Mapping conveniences over rules the server enforces anyway.
|
||||||
over rules the server enforces anyway.
|
|
||||||
|
|
||||||
Everything is delegated from `document`, because each step swaps the whole
|
Everything is delegated from `document`, because each step swaps the whole
|
||||||
block: a listener bound to a select would be thrown away with it.
|
block: a listener bound to a select would be thrown away with it.
|
||||||
#}
|
#}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function sync() {
|
let selectedSource = "";
|
||||||
const selects = Array.from(document.querySelectorAll("[data-source-select]"));
|
|
||||||
// A source column can fill one destination only. The mapping refuses a
|
function sync(workbench) {
|
||||||
// repeat outright; this stops it being offered, so the refusal is not
|
if (!workbench) return;
|
||||||
// how the user finds out.
|
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 taken = new Set(selects.map((select) => select.value).filter(Boolean));
|
||||||
for (const select of selects) {
|
for (const select of selects) {
|
||||||
for (const option of select.options) {
|
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 && 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.
|
rows.forEach((row, index) => {
|
||||||
const cell = select.closest("tr")?.querySelector("[data-example-cell]");
|
const option = selects[index].selectedOptions[0];
|
||||||
if (cell) cell.textContent = select.selectedOptions[0]?.dataset.example ?? "";
|
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) {
|
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);
|
document.addEventListener("click", function (event) {
|
||||||
sync();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
than the form itself, so the scope, the table and the CSV the user chose stay
|
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.
|
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
|
Import is the form's default action, including an implicit submit with Enter.
|
||||||
real browser submit, so the plain submit button is the one that downloads and
|
Download overrides the action on its own button, so only that explicit click
|
||||||
every other button carries an `hx-post` of its own.
|
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">
|
hx-target="#import-step" hx-swap="outerHTML" hx-disabled-elt="this">
|
||||||
{% include "pages/import_export/import/step.html" %}
|
{% include "pages/import_export/import/step.html" %}
|
||||||
</form>
|
</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>
|
<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>
|
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
||||||
<button type="button" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-continue") }}</button>
|
<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">
|
<div class="builder-section">
|
||||||
<h2>{{ nav.tr_args("import-mapping-heading", [("table", step.table_name.clone())]) }}</h2>
|
<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("import-mapping-hint") }}</p>
|
||||||
<p class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</p>
|
<div class="mapping-summary" data-mapping-summary
|
||||||
<table class="builder-table mapping-table">
|
data-mapped-label="{{ nav.tr("import-summary-mapped-short") }}"
|
||||||
<thead>
|
data-attention-label="{{ nav.tr("import-summary-attention-short") }}">
|
||||||
<tr>
|
<span><strong data-mapped-count>{{ step.mapped }}</strong> {{ nav.tr("import-summary-mapped-short") }}</span>
|
||||||
<th>{{ nav.tr("import-th-destination") }}</th>
|
<span><strong data-attention-count>{{ step.attention }}</strong> {{ nav.tr("import-summary-attention-short") }}</span>
|
||||||
<th>{{ nav.tr("import-th-required") }}</th>
|
<span class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</span>
|
||||||
<th>{{ nav.tr("import-th-source") }}</th>
|
</div>
|
||||||
<th>{{ nav.tr("import-th-example") }}</th>
|
|
||||||
</tr>
|
<div class="mapping-workbench" data-mapping-workbench
|
||||||
</thead>
|
data-unmapped-label="{{ nav.tr("import-destination-unmapped") }}">
|
||||||
<tbody>
|
<section class="source-palette">
|
||||||
{% for row in step.rows %}
|
<h3>{{ nav.tr("import-csv-columns") }}</h3>
|
||||||
<tr>
|
<p class="hint">{{ nav.tr("import-csv-columns-hint") }}</p>
|
||||||
<td><code>{{ row.name }}</code></td>
|
<div class="source-chips">
|
||||||
<td>{% if row.required %}{{ nav.tr("common-yes") }}{% else %}<span class="hint">{{ nav.tr("common-no") }}</span>{% endif %}</td>
|
{% for source in step.sources %}
|
||||||
<td>
|
<button type="button" class="source-chip" draggable="true"
|
||||||
{#
|
data-source-chip data-source-position="{{ source.position }}"
|
||||||
The destination travels as its stable identity, so a rename
|
aria-label="{{ nav.tr_args("import-drag-source", [("column", source.label.clone())]) }}">
|
||||||
between here and Import moves the mapping with the column
|
<code>{% if source.name.is_empty() %}{{ nav.tr_args("import-source-column", [("position", source.position.to_string())]) }}{% else %}{{ source.name }}{% endif %}</code>
|
||||||
instead of leaving it pointing at a name.
|
<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 }}">
|
<input type="hidden" name="destination" value="{{ row.key }}">
|
||||||
<select name="source_position" data-source-select>
|
<div class="destination-name">
|
||||||
<option value="">{{ nav.tr("import-source-none") }}</option>
|
<code>{{ row.name }}</code>
|
||||||
{% for option in step.sources %}
|
{% if row.required %}<span class="tag">{{ nav.tr("import-required-short") }}</span>{% endif %}
|
||||||
<option value="{{ option.position }}" data-example="{{ option.example }}"{% if row.takes(option) %} selected{% endif %}>{{ option.label }}</option>
|
</div>
|
||||||
{% endfor %}
|
<span class="mapping-arrow" aria-hidden="true">←</span>
|
||||||
</select>
|
<label>
|
||||||
</td>
|
<span>{{ nav.tr("import-value-from") }}</span>
|
||||||
<td class="example" data-example-cell>{{ row.example }}</td>
|
<select name="source_position" data-source-select>
|
||||||
</tr>
|
<option value="">{{ nav.tr("import-destination-unmapped") }}</option>
|
||||||
{% endfor %}
|
{% for source in step.sources %}
|
||||||
</tbody>
|
<option value="{{ source.position }}" data-example="{{ source.example }}"{% if row.takes(source) %} selected{% endif %}>{{ source.label }}</option>
|
||||||
</table>
|
{% 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>
|
||||||
<div class="form-actions">
|
<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>
|
<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>
|
||||||
<div class="form-actions">
|
<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>
|
<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" hx-post="/admin/import" hx-include="closest form"
|
||||||
<button type="submit" formnovalidate>{{ nav.tr("import-download-prepared") }}</button>
|
|
||||||
<button type="button" hx-post="/admin/import" hx-include="closest form"
|
|
||||||
hx-target="#submission-status" hx-swap="innerHTML">{{ nav.tr("import-import-rows") }}</button>
|
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>
|
</div>
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
keyboard. With scripting off the dropdown lists every table it was rendered
|
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.
|
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
|
Bound again after every HTMX step swap, which replaces both selects with
|
||||||
fields — and with them both selects — with fresh nodes.
|
fresh nodes when the user returns to the source step.
|
||||||
#}
|
#}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user