import improvements2
This commit is contained in:
@@ -80,52 +80,6 @@ fn is_read_omitted_column(name: &str) -> bool {
|
||||
.any(|column| column.name == name)
|
||||
}
|
||||
|
||||
/// The columns an import may write into: the destinations the mapping step
|
||||
/// offers, in the order the table declares them.
|
||||
///
|
||||
/// This is the list the whole import rests on. A source position lands in a
|
||||
/// column because the user picked it from here, so what is not here cannot be
|
||||
/// written to at all — which is why the exclusions are the server's own flags
|
||||
/// rather than a guess:
|
||||
///
|
||||
/// * `is_primary_key` — `id` comes from a sequence.
|
||||
/// * `read_only` — the server sets this for a quantity-ledger column and for a
|
||||
/// link projection, and refuses an insert that names either. Accounting
|
||||
/// columns are marked `generated` but *not* read-only, so they stay: they are
|
||||
/// a user's to fill in.
|
||||
/// * system columns, except the ones an insert actually takes. `deleted` is
|
||||
/// offered, because writing it is how a file that recorded deleted rows loads
|
||||
/// back as deleted rows; `row_revision` and `created_at` are not, because the
|
||||
/// server assigns them and answers `Invalid column` to anything else.
|
||||
pub(crate) fn importable_columns(schema: &TableStructureResponse) -> Vec<String> {
|
||||
schema
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !column.is_primary_key && !column.read_only)
|
||||
.filter(|column| {
|
||||
!is_system_column(&column.name) || is_importable_system_column(&column.name)
|
||||
})
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The writable columns the table declares `NOT NULL`.
|
||||
///
|
||||
/// Shown in the preview as a warning rather than enforced as a rule: a
|
||||
/// `NOT NULL` column may still have a default, and `information_schema` does
|
||||
/// not say which do. Refusing here would block imports the server would have
|
||||
/// accepted, so the server stays the one that decides and this only tells the
|
||||
/// user which columns are the likely reason if it refuses.
|
||||
pub(crate) fn required_columns(schema: &TableStructureResponse) -> Vec<String> {
|
||||
schema
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !column.is_primary_key && !column.read_only)
|
||||
.filter(|column| !is_system_column(&column.name) && !column.is_nullable)
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> {
|
||||
schema
|
||||
.columns
|
||||
@@ -234,64 +188,5 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// What the mapping step may offer as a destination. `deleted` is in,
|
||||
/// because an insert takes it; the columns the server assigns and the ones
|
||||
/// it marks read-only are out, because an insert naming them is refused.
|
||||
#[test]
|
||||
fn only_the_columns_an_insert_takes_are_offered_as_destinations() {
|
||||
let ledger = TableColumn {
|
||||
read_only: true,
|
||||
..column("stock", false)
|
||||
};
|
||||
// Accounting columns are generated companions, but the server leaves
|
||||
// them writable — so they are a destination like any other.
|
||||
let accounting = TableColumn {
|
||||
generated: true,
|
||||
generated_from: "accounting".to_string(),
|
||||
..column("debit", false)
|
||||
};
|
||||
let schema = TableStructureResponse {
|
||||
columns: vec![
|
||||
column("id", true),
|
||||
column("deleted", false),
|
||||
column("row_revision", false),
|
||||
column("number", false),
|
||||
ledger,
|
||||
accounting,
|
||||
column("created_at", false),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
importable_columns(&schema),
|
||||
vec![
|
||||
"deleted".to_string(),
|
||||
"number".to_string(),
|
||||
"debit".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the user's own `NOT NULL` columns are reported as required.
|
||||
/// `deleted` is `NOT NULL` on every managed table and has a default, so
|
||||
/// reporting it would warn about every import ever prepared.
|
||||
#[test]
|
||||
fn required_columns_are_the_users_own_not_null_ones() {
|
||||
let schema = TableStructureResponse {
|
||||
columns: vec![
|
||||
column("id", true),
|
||||
TableColumn {
|
||||
is_nullable: false,
|
||||
..column("deleted", false)
|
||||
},
|
||||
TableColumn {
|
||||
is_nullable: false,
|
||||
..column("number", false)
|
||||
},
|
||||
column("note", false),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(required_columns(&schema), vec!["number".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
227
web/src/pages/import_export/import/destination.rs
Normal file
227
web/src/pages/import_export/import/destination.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
//! 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
|
||||
//! gets one row, and the row asks where its value comes from. That is what
|
||||
//! makes "one destination filled twice" impossible to express rather than
|
||||
//! merely refused — each destination appears exactly once, by construction.
|
||||
|
||||
use crate::definitions::table_structure::{TableColumn, TableStructureResponse};
|
||||
|
||||
use super::super::common::schema::{is_importable_system_column, is_system_column};
|
||||
|
||||
/// How a destination column is named in the form.
|
||||
///
|
||||
/// Not by its display name. A user column has a stable identity the server
|
||||
/// assigns once — `column_id` — and it keeps that identity through a rename, so
|
||||
/// a mapping built before a rename still means the same column afterwards
|
||||
/// rather than silently pointing at whatever now answers to the old name.
|
||||
///
|
||||
/// The system columns have no such id (`column_id` is zero for them), so they
|
||||
/// are named by the one thing they do have. That is safe for exactly the reason
|
||||
/// the id exists: a system column's name is the server's and cannot be renamed.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DestinationKey {
|
||||
Column(i64),
|
||||
System(String),
|
||||
}
|
||||
|
||||
impl DestinationKey {
|
||||
fn of(column: &TableColumn) -> Self {
|
||||
if column.column_id == 0 {
|
||||
Self::System(column.name.clone())
|
||||
} else {
|
||||
Self::Column(column.column_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the hidden field carries.
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
match self {
|
||||
Self::Column(id) => format!("id:{id}"),
|
||||
Self::System(name) => format!("system:{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(raw: &str) -> Option<Self> {
|
||||
if let Some(id) = raw.strip_prefix("id:") {
|
||||
return id.parse().ok().map(Self::Column);
|
||||
}
|
||||
raw.strip_prefix("system:")
|
||||
.map(|name| Self::System(name.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One column an import may write into.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DestinationColumn {
|
||||
pub key: DestinationKey,
|
||||
/// The name the table currently shows, which is what the page displays and
|
||||
/// what the prepared CSV's header says. The identity is `key`.
|
||||
pub name: String,
|
||||
/// Declared `NOT NULL`. A warning in the preview rather than a rule: the
|
||||
/// column may have a default, and `information_schema` does not say which
|
||||
/// do, so refusing here would block imports the server would accept.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// The columns of `schema` an import may write into, in the order the table
|
||||
/// declares them.
|
||||
///
|
||||
/// The exclusions are the server's own flags rather than a guess:
|
||||
///
|
||||
/// * `is_primary_key` — `id` comes from a sequence.
|
||||
/// * `read_only` — set for a quantity-ledger column and for a link projection,
|
||||
/// and an insert naming either is refused. Accounting columns are marked
|
||||
/// `generated` but *not* read-only, so they stay: they are a user's to fill
|
||||
/// in.
|
||||
/// * system columns, except the ones an insert actually takes. `deleted` is
|
||||
/// offered, because writing it is how a file that recorded deleted rows loads
|
||||
/// back as deleted rows; `row_revision` and `created_at` are not, because the
|
||||
/// server assigns them and answers `Invalid column` to anything else.
|
||||
pub(crate) fn destination_columns(schema: &TableStructureResponse) -> Vec<DestinationColumn> {
|
||||
schema
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !column.is_primary_key && !column.read_only)
|
||||
.filter(|column| {
|
||||
!is_system_column(&column.name) || is_importable_system_column(&column.name)
|
||||
})
|
||||
.map(|column| DestinationColumn {
|
||||
key: DestinationKey::of(column),
|
||||
name: column.name.clone(),
|
||||
// A system column's nullability is the server's business, and
|
||||
// `deleted` is NOT NULL with a default on every managed table —
|
||||
// reporting it would warn about every import ever prepared.
|
||||
required: !column.is_nullable && !is_system_column(&column.name),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The column a posted key names, in the table as it stands right now.
|
||||
///
|
||||
/// `None` means the table no longer has it — dropped, or made read-only, while
|
||||
/// the form sat open. The caller turns that into a refusal; it must never
|
||||
/// become "write it somewhere else".
|
||||
pub(crate) fn resolve<'a>(
|
||||
columns: &'a [DestinationColumn],
|
||||
raw: &str,
|
||||
) -> Option<&'a DestinationColumn> {
|
||||
let key = DestinationKey::parse(raw)?;
|
||||
columns.iter().find(|column| column.key == key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn column(name: &str, column_id: i64) -> TableColumn {
|
||||
TableColumn {
|
||||
name: name.to_string(),
|
||||
data_type: "TEXT".to_string(),
|
||||
is_nullable: true,
|
||||
column_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> TableStructureResponse {
|
||||
TableStructureResponse {
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
is_primary_key: true,
|
||||
..column("id", 0)
|
||||
},
|
||||
column("deleted", 0),
|
||||
column("row_revision", 0),
|
||||
TableColumn {
|
||||
is_nullable: false,
|
||||
..column("number", 42)
|
||||
},
|
||||
// A quantity-ledger column: the server refuses an insert that
|
||||
// names it, so it is not a destination.
|
||||
TableColumn {
|
||||
read_only: true,
|
||||
..column("stock", 57)
|
||||
},
|
||||
// Accounting columns are generated companions but stay
|
||||
// writable, so they are destinations like any other.
|
||||
TableColumn {
|
||||
generated: true,
|
||||
generated_from: "accounting".to_string(),
|
||||
..column("debit", 81)
|
||||
},
|
||||
column("created_at", 0),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_columns_an_insert_takes_are_offered() {
|
||||
let columns = destination_columns(&schema());
|
||||
assert_eq!(
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| column.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["deleted", "number", "debit"]
|
||||
);
|
||||
}
|
||||
|
||||
/// A user column is identified by the id the server gave it, so a rename
|
||||
/// cannot move a mapping onto a different column. A system column has no
|
||||
/// id and no rename, so its name is identity enough.
|
||||
#[test]
|
||||
fn a_user_column_is_named_by_its_stable_id_and_a_system_one_by_its_name() {
|
||||
let columns = destination_columns(&schema());
|
||||
assert_eq!(columns[0].key.encode(), "system:deleted");
|
||||
assert_eq!(columns[1].key.encode(), "id:42");
|
||||
|
||||
for column in &columns {
|
||||
assert_eq!(
|
||||
DestinationKey::parse(&column.key.encode()).as_ref(),
|
||||
Some(&column.key)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the id: the mapping survives the rename, and follows the
|
||||
/// column rather than the name.
|
||||
#[test]
|
||||
fn a_renamed_column_is_still_the_same_destination() {
|
||||
let posted = destination_columns(&schema())[1].key.encode();
|
||||
|
||||
let mut renamed = schema();
|
||||
renamed.columns[3].name = "invoice_number".to_string();
|
||||
let columns = destination_columns(&renamed);
|
||||
let resolved =
|
||||
resolve(&columns, &posted).expect("column 42 is column 42 whatever it is called");
|
||||
assert_eq!(resolved.name, "invoice_number");
|
||||
}
|
||||
|
||||
/// A destination that is gone, or that the table has since made read-only,
|
||||
/// resolves to nothing — which the caller has to refuse rather than guess
|
||||
/// past.
|
||||
#[test]
|
||||
fn a_destination_the_table_no_longer_offers_resolves_to_nothing() {
|
||||
let columns = destination_columns(&schema());
|
||||
assert!(resolve(&columns, "id:57").is_none());
|
||||
assert!(resolve(&columns, "id:999").is_none());
|
||||
assert!(resolve(&columns, "system:row_revision").is_none());
|
||||
assert!(resolve(&columns, "number").is_none());
|
||||
assert!(resolve(&columns, "").is_none());
|
||||
}
|
||||
|
||||
/// Only the user's own `NOT NULL` columns are reported as required.
|
||||
#[test]
|
||||
fn required_is_the_users_own_not_null_columns() {
|
||||
let columns = destination_columns(&schema());
|
||||
assert_eq!(
|
||||
columns
|
||||
.iter()
|
||||
.filter(|column| column.required)
|
||||
.map(|column| column.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["number"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ use axum::{
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
// A mapping posts `mapping` once per source position, and `axum::Form`
|
||||
// (serde_urlencoded) cannot decode repeated keys into a `Vec`.
|
||||
// The mapping posts `destination` and `source_position` once per destination
|
||||
// row, and `axum::Form` (serde_urlencoded) cannot decode repeated keys into a
|
||||
// `Vec`.
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
@@ -22,16 +23,12 @@ use crate::{
|
||||
use super::{
|
||||
super::common::{
|
||||
loader::LoadError,
|
||||
schema::{column_types, csv_value, importable_columns, required_columns},
|
||||
schema::{column_types, csv_value},
|
||||
},
|
||||
destination::{DestinationColumn, destination_columns, resolve},
|
||||
loader::load_page,
|
||||
prepare::{
|
||||
Prepared, SourceMode, canonical_csv, prepare, read_mapping, read_source, suggest_mapping,
|
||||
template_csv,
|
||||
},
|
||||
state::{
|
||||
ImportForm, MappingRow, MappingStep, PreviewStep, Step, TemplateRow, TemplateStep,
|
||||
},
|
||||
prepare::{Prepared, Source, canonical_csv, prepare, read_mapping, read_source},
|
||||
state::{ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption, Step},
|
||||
ui,
|
||||
};
|
||||
|
||||
@@ -42,17 +39,25 @@ const PREVIEW_ROWS: usize = 20;
|
||||
///
|
||||
/// Loaded again at every step rather than carried in the form. The destination
|
||||
/// list is what makes a mapping mean anything, so it comes from the table each
|
||||
/// time: a column dropped or renamed while the form sat open turns into a
|
||||
/// refusal on the next click instead of a value written somewhere else.
|
||||
/// time: a column dropped or made read-only while the form sat open turns into
|
||||
/// a refusal on the next click instead of a value written somewhere else.
|
||||
struct Destination {
|
||||
table_name: String,
|
||||
/// The columns a mapping may point at.
|
||||
columns: Vec<String>,
|
||||
/// Of those, the ones declared `NOT NULL`.
|
||||
required: Vec<String>,
|
||||
columns: Vec<DestinationColumn>,
|
||||
types: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Destination {
|
||||
/// The destination names, for the parts of the preparation that only need
|
||||
/// to know which columns exist.
|
||||
fn names(&self) -> Vec<String> {
|
||||
self.columns
|
||||
.iter()
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /admin/import
|
||||
pub(crate) async fn import_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match load_page(state, &headers, ImportForm::default(), Step::Source).await {
|
||||
@@ -74,11 +79,8 @@ pub(crate) async fn source_step(
|
||||
render_step(state, &headers, form, Step::Source).await
|
||||
}
|
||||
|
||||
/// POST /admin/import/prepare — the second step: the mapping table for a file,
|
||||
/// or the column chooser for a template.
|
||||
///
|
||||
/// Also where the template's move buttons land, because reordering is a change
|
||||
/// to the same step rather than a step of its own.
|
||||
/// POST /admin/import/prepare — the mapping: one row per writable destination
|
||||
/// column, asking where its value comes from.
|
||||
pub(crate) async fn prepare_step(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -92,43 +94,48 @@ pub(crate) async fn prepare_step(
|
||||
Ok(destination) => destination,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let mode = match form.mode(locale) {
|
||||
Ok(mode) => mode,
|
||||
let source = match read_source(locale, &form.csv_data) {
|
||||
Ok(source) => source,
|
||||
Err(message) => return reject(&headers, message),
|
||||
};
|
||||
|
||||
let step = match mode {
|
||||
SourceMode::Template => template_step(&form, &destination),
|
||||
SourceMode::Header | SourceMode::Data => {
|
||||
let source = match read_source(locale, &form.csv_data, mode) {
|
||||
Ok(source) => source,
|
||||
Err(message) => return reject(&headers, message),
|
||||
};
|
||||
// A mapping that does not cover this file was built against a
|
||||
// different one — the user edited the CSV and came back. Start it
|
||||
// over rather than lining up positions that no longer correspond.
|
||||
let chosen = if form.mapping.len() == source.width {
|
||||
form.mapping.clone()
|
||||
} else {
|
||||
suggest_mapping(&source, &destination.columns)
|
||||
};
|
||||
let examples = source.examples();
|
||||
Step::Mapping(MappingStep {
|
||||
table_name: destination.table_name.clone(),
|
||||
columns: destination.columns.clone(),
|
||||
rows: (0..source.width)
|
||||
.map(|position| MappingRow {
|
||||
position: position + 1,
|
||||
source_name: source.source_name(position),
|
||||
example: examples.get(position).cloned().unwrap_or_default(),
|
||||
target: chosen.get(position).cloned().unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
source_rows: source.rows.len(),
|
||||
has_source_names: source.header.is_some(),
|
||||
// What the user has already answered, if they are coming back from the
|
||||
// preview. Keyed by the destination's identity rather than by row order, so
|
||||
// a table that gained or lost a column keeps the answers for the rest.
|
||||
let answered = form
|
||||
.rows()
|
||||
.into_iter()
|
||||
.filter_map(|(key, position)| Some((key.to_string(), position?)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let step = Step::Mapping(MappingStep {
|
||||
table_name: destination.table_name.clone(),
|
||||
rows: destination
|
||||
.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),
|
||||
source_rows: source.rows.len(),
|
||||
});
|
||||
render_step(state, &headers, form, step).await
|
||||
}
|
||||
|
||||
@@ -142,12 +149,12 @@ pub(crate) async fn preview_step(
|
||||
return rejection;
|
||||
}
|
||||
let locale = Locale::from_headers(&headers);
|
||||
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await {
|
||||
let (destination, source, prepared) = match prepared(state.clone(), &headers, &form).await {
|
||||
Ok(parts) => parts,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
let source_names = source_names(locale, &form);
|
||||
let sources = source_options(locale, &source);
|
||||
let step = Step::Preview(PreviewStep {
|
||||
table_name: destination.table_name.clone(),
|
||||
columns: prepared.columns.clone(),
|
||||
@@ -157,16 +164,13 @@ pub(crate) async fn preview_step(
|
||||
ignored: prepared
|
||||
.ignored
|
||||
.iter()
|
||||
.map(|position| match source_names.get(position - 1) {
|
||||
Some(name) if !name.is_empty() => format!("{position} ({name})"),
|
||||
_ => position.to_string(),
|
||||
})
|
||||
.filter_map(|position| Some(sources.get(position - 1)?.label.clone()))
|
||||
.collect(),
|
||||
missing_required: destination
|
||||
.required
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| !prepared.columns.contains(column))
|
||||
.cloned()
|
||||
.filter(|column| column.required && !prepared.columns.contains(&column.name))
|
||||
.map(|column| column.name.clone())
|
||||
.collect(),
|
||||
omitted: prepared.omitted.clone(),
|
||||
csv: canonical_csv(&prepared),
|
||||
@@ -192,7 +196,7 @@ pub(crate) async fn import_csv(
|
||||
Ok(target) => target,
|
||||
Err(message) => return reject(&headers, message),
|
||||
};
|
||||
let (destination, prepared) = match prepared(state.clone(), &headers, &form).await {
|
||||
let (destination, _, prepared) = match prepared(state.clone(), &headers, &form).await {
|
||||
Ok(parts) => parts,
|
||||
Err(response) => return response,
|
||||
};
|
||||
@@ -206,7 +210,9 @@ pub(crate) async fn import_csv(
|
||||
tr!(
|
||||
locale,
|
||||
"import-err-csv-row",
|
||||
"row" => (index + 1) as i64,
|
||||
// The header is row 1 of the file the user is looking at,
|
||||
// so the first data row is row 2.
|
||||
"row" => (index + 2) as i64,
|
||||
"error" => error,
|
||||
)
|
||||
})
|
||||
@@ -261,39 +267,24 @@ pub(crate) async fn download_prepared(
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
let (destination, prepared) = match prepared(state, &headers, &form).await {
|
||||
let (destination, _, prepared) = match prepared(state, &headers, &form).await {
|
||||
Ok(parts) => parts,
|
||||
Err(response) => return response,
|
||||
};
|
||||
attachment(
|
||||
canonical_csv(&prepared),
|
||||
&format!("{}_prepared.csv", destination.table_name),
|
||||
)
|
||||
}
|
||||
let csv = canonical_csv(&prepared);
|
||||
let filename = format!("{}_prepared.csv", destination.table_name);
|
||||
|
||||
/// POST /admin/import/template.csv — the chosen columns as a header row, and
|
||||
/// nothing else.
|
||||
pub(crate) async fn download_template(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ImportForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
let mut response = csv.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
||||
);
|
||||
if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_DISPOSITION, value);
|
||||
}
|
||||
let locale = Locale::from_headers(&headers);
|
||||
let destination = match destination(state, &headers, &form).await {
|
||||
Ok(destination) => destination,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let columns = match template_selection(locale, &form, &destination) {
|
||||
Ok(columns) => columns,
|
||||
Err(message) => return reject(&headers, message),
|
||||
};
|
||||
attachment(
|
||||
template_csv(&columns),
|
||||
&format!("{}_template.csv", destination.table_name),
|
||||
)
|
||||
response
|
||||
}
|
||||
|
||||
/// The table this import writes into, once the form's scope and table have been
|
||||
@@ -340,7 +331,7 @@ async fn destination(
|
||||
.remove(&table_name)
|
||||
.ok_or_else(|| unavailable(headers, tr!(locale, "import-err-missing-structure")))?;
|
||||
|
||||
let columns = importable_columns(&structure);
|
||||
let columns = destination_columns(&structure);
|
||||
if columns.is_empty() {
|
||||
return Err(reject(
|
||||
headers,
|
||||
@@ -353,131 +344,72 @@ async fn destination(
|
||||
}
|
||||
Ok(Destination {
|
||||
table_name,
|
||||
required: required_columns(&structure),
|
||||
types: column_types(&structure),
|
||||
columns,
|
||||
})
|
||||
}
|
||||
|
||||
/// The prepared import: the source read as the user said it is laid out, the
|
||||
/// mapping checked against the table as it stands, and the two applied.
|
||||
/// The prepared import: the file read, the mapping resolved against the table
|
||||
/// as it stands, and the two applied.
|
||||
///
|
||||
/// Shared by preview, download and import so that all three are the same file
|
||||
/// — looking at it and importing it cannot disagree about what it contains.
|
||||
/// Shared by preview, download and import so that all three are the same file —
|
||||
/// looking at it and importing it cannot disagree about what it contains.
|
||||
async fn prepared(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
form: &ImportForm,
|
||||
) -> Result<(Destination, Prepared), Response> {
|
||||
) -> Result<(Destination, Source, Prepared), Response> {
|
||||
let locale = Locale::from_headers(headers);
|
||||
let destination = destination(state, headers, form).await?;
|
||||
let mode = form.mode(locale).map_err(|message| reject(headers, message))?;
|
||||
if !mode.reads_a_file() {
|
||||
return Err(reject(
|
||||
headers,
|
||||
tr!(locale, "import-err-template-has-no-source"),
|
||||
));
|
||||
}
|
||||
let source = read_source(locale, &form.csv_data, mode)
|
||||
.map_err(|message| reject(headers, message))?;
|
||||
let mapping = read_mapping(locale, &form.mapping, source.width, &destination.columns)
|
||||
.map_err(|message| reject(headers, message))?;
|
||||
let prepared = prepare(&mapping, &source, &destination.columns);
|
||||
Ok((destination, prepared))
|
||||
}
|
||||
let source = read_source(locale, &form.csv_data).map_err(|message| reject(headers, message))?;
|
||||
|
||||
/// The column chooser, with the arrangement the form carries and whatever move
|
||||
/// button was pressed applied to it.
|
||||
fn template_step(form: &ImportForm, destination: &Destination) -> Step {
|
||||
let mut order = arranged_columns(form, destination);
|
||||
if let (Some(action), Some(index)) = (form.action.as_deref(), form.index) {
|
||||
let swap_with = match action {
|
||||
"up" if index > 0 => Some(index - 1),
|
||||
"down" if index + 1 < order.len() => Some(index + 1),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(other) = swap_with {
|
||||
order.swap(index, other);
|
||||
}
|
||||
// Every posted destination has to still exist and still be writable. A key
|
||||
// that resolves to nothing is refused rather than skipped: skipping it
|
||||
// would quietly import a subset of what the preview promised.
|
||||
let mut chosen = Vec::new();
|
||||
for (key, position) in form.rows() {
|
||||
let column = resolve(&destination.columns, key).ok_or_else(|| {
|
||||
reject(
|
||||
headers,
|
||||
tr!(
|
||||
locale,
|
||||
"import-err-destination-missing",
|
||||
"table" => destination.table_name.clone(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
chosen.push((column.name.clone(), position));
|
||||
}
|
||||
|
||||
let chosen = order
|
||||
.iter()
|
||||
.filter(|column| form.template_columns.contains(column))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let last = order.len().saturating_sub(1);
|
||||
Step::Template(TemplateStep {
|
||||
table_name: destination.table_name.clone(),
|
||||
rows: order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| TemplateRow {
|
||||
index,
|
||||
chosen: form.template_columns.contains(name),
|
||||
required: destination.required.contains(name),
|
||||
name: name.clone(),
|
||||
first: index == 0,
|
||||
last: index == last,
|
||||
})
|
||||
.collect(),
|
||||
header: if chosen.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
template_csv(&chosen)
|
||||
},
|
||||
chosen: chosen.len(),
|
||||
})
|
||||
let assignments =
|
||||
read_mapping(locale, &chosen, &source).map_err(|message| reject(headers, message))?;
|
||||
let prepared = prepare(&assignments, &source, &destination.names());
|
||||
Ok((destination, source, prepared))
|
||||
}
|
||||
|
||||
/// The destination columns in the order the template step has them arranged.
|
||||
///
|
||||
/// The table's own order until the user moves something, and thereafter what
|
||||
/// the form carries — minus anything the table no longer has, plus anything it
|
||||
/// has gained, so a table changed mid-arrangement neither drops a column from
|
||||
/// the chooser nor offers one that is gone.
|
||||
fn arranged_columns(form: &ImportForm, destination: &Destination) -> Vec<String> {
|
||||
let mut order = form
|
||||
.template_order
|
||||
.iter()
|
||||
.filter(|column| destination.columns.contains(column))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
order.dedup();
|
||||
for column in &destination.columns {
|
||||
if !order.contains(column) {
|
||||
order.push(column.clone());
|
||||
}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// The template's columns, in the arranged order, checked for being answered.
|
||||
fn template_selection(
|
||||
locale: Locale,
|
||||
form: &ImportForm,
|
||||
destination: &Destination,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let chosen = arranged_columns(form, destination)
|
||||
.into_iter()
|
||||
.filter(|column| form.template_columns.contains(column))
|
||||
.collect::<Vec<_>>();
|
||||
if chosen.is_empty() {
|
||||
return Err(tr!(locale, "import-err-template-empty"));
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
/// The source's own names for its positions, for labelling the ignored ones in
|
||||
/// the summary. Best effort: a source that cannot be read at all has already
|
||||
/// been refused elsewhere.
|
||||
fn source_names(locale: Locale, form: &ImportForm) -> Vec<String> {
|
||||
form.mode(locale)
|
||||
.ok()
|
||||
.filter(|mode| *mode == SourceMode::Header)
|
||||
.and_then(|mode| read_source(locale, &form.csv_data, mode).ok())
|
||||
.and_then(|source| source.header)
|
||||
.unwrap_or_default()
|
||||
/// The file's columns as every picker offers them: `hl — position 3`, and just
|
||||
/// the position when the file left the name blank.
|
||||
fn source_options(locale: Locale, source: &Source) -> Vec<SourceOption> {
|
||||
(0..source.width())
|
||||
.map(|index| {
|
||||
let position = (index + 1) as i64;
|
||||
let name = source.name(index).trim();
|
||||
SourceOption {
|
||||
position: index + 1,
|
||||
label: if name.is_empty() {
|
||||
tr!(locale, "import-source-unnamed", "position" => position)
|
||||
} else {
|
||||
tr!(
|
||||
locale,
|
||||
"import-source-option",
|
||||
"name" => name.to_string(),
|
||||
"position" => position,
|
||||
)
|
||||
},
|
||||
example: source.example(index).to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One prepared row as the bulk insert takes it: values under their destination
|
||||
@@ -519,22 +451,8 @@ async fn render_step(
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment(csv: String, filename: &str) -> Response {
|
||||
let mut response = csv.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
||||
);
|
||||
if let Ok(value) = HeaderValue::try_from(format!("attachment; filename=\"{filename}\"")) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_DISPOSITION, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Something this crate refused before any of it reached the backend: an
|
||||
/// unknown scope, a mapping that does not fit the file, a cell no type accepts.
|
||||
/// unknown scope, a position the file does not have, a cell no type accepts.
|
||||
/// The buttons target `#submission-status`, so the answer is the alert fragment
|
||||
/// rather than a whole page.
|
||||
fn reject(headers: &HeaderMap, message: String) -> Response {
|
||||
@@ -569,134 +487,34 @@ fn unavailable(headers: &HeaderMap, message: String) -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
fn source() -> Source {
|
||||
read_source(
|
||||
Locale::default(),
|
||||
"\"1\",\"1b\",\"hl\",\"\",\"s\"\n\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\"\n",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn destination() -> Destination {
|
||||
Destination {
|
||||
table_name: "customers".to_string(),
|
||||
columns: strings(&["name", "company_number", "active"]),
|
||||
required: strings(&["name"]),
|
||||
types: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn template_rows(step: &Step) -> Vec<(String, bool)> {
|
||||
match step {
|
||||
Step::Template(step) => step
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| (row.name.clone(), row.chosen))
|
||||
.collect(),
|
||||
_ => panic!("not the template step"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chooser starts in the table's own order, with nothing ticked.
|
||||
/// Every picker offers the file's columns by name *and* position, because
|
||||
/// the name is what a person recognises and the position is what is stored.
|
||||
#[test]
|
||||
fn the_template_starts_in_table_order() {
|
||||
let step = template_step(&ImportForm::default(), &destination());
|
||||
assert_eq!(
|
||||
template_rows(&step),
|
||||
vec![
|
||||
("name".to_string(), false),
|
||||
("company_number".to_string(), false),
|
||||
("active".to_string(), false),
|
||||
]
|
||||
);
|
||||
fn a_source_column_is_offered_by_name_and_position() {
|
||||
let options = source_options(Locale::default(), &source());
|
||||
|
||||
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('3'), "{}", options[2].label);
|
||||
assert_eq!(options[2].example, "value-a");
|
||||
}
|
||||
|
||||
/// Moving a row rearranges the chooser, and the generated header follows
|
||||
/// the arrangement rather than the table.
|
||||
/// A file that left a column unnamed still has that column, and it is still
|
||||
/// selectable — by the one thing it does have.
|
||||
#[test]
|
||||
fn moving_a_row_rearranges_the_generated_header() {
|
||||
let form = ImportForm {
|
||||
template_order: strings(&["name", "company_number", "active"]),
|
||||
template_columns: strings(&["name", "active"]),
|
||||
action: Some("down".to_string()),
|
||||
index: Some(0),
|
||||
..ImportForm::default()
|
||||
};
|
||||
let step = template_step(&form, &destination());
|
||||
assert_eq!(
|
||||
template_rows(&step)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect::<Vec<_>>(),
|
||||
strings(&["company_number", "name", "active"])
|
||||
);
|
||||
match step {
|
||||
Step::Template(step) => {
|
||||
assert_eq!(step.header, "\"name\",\"active\"\n");
|
||||
assert_eq!(step.chosen, 2);
|
||||
}
|
||||
_ => panic!("not the template step"),
|
||||
}
|
||||
}
|
||||
fn an_unnamed_source_column_is_offered_by_position_alone() {
|
||||
let options = source_options(Locale::default(), &source());
|
||||
|
||||
/// A move off either end does nothing rather than wrapping around.
|
||||
#[test]
|
||||
fn a_move_past_the_end_is_ignored() {
|
||||
for (action, index) in [("up", 0), ("down", 2)] {
|
||||
let form = ImportForm {
|
||||
template_order: strings(&["name", "company_number", "active"]),
|
||||
action: Some(action.to_string()),
|
||||
index: Some(index),
|
||||
..ImportForm::default()
|
||||
};
|
||||
assert_eq!(
|
||||
template_rows(&template_step(&form, &destination()))
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect::<Vec<_>>(),
|
||||
strings(&["name", "company_number", "active"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A table changed while the chooser was open: the column it lost leaves
|
||||
/// the arrangement, and the one it gained joins the end of it.
|
||||
#[test]
|
||||
fn the_arrangement_follows_the_table_it_is_for() {
|
||||
let form = ImportForm {
|
||||
template_order: strings(&["active", "gone", "name"]),
|
||||
..ImportForm::default()
|
||||
};
|
||||
assert_eq!(
|
||||
arranged_columns(&form, &destination()),
|
||||
strings(&["active", "name", "company_number"])
|
||||
);
|
||||
}
|
||||
|
||||
/// Generating a template with nothing ticked is refused rather than
|
||||
/// producing an empty header.
|
||||
#[test]
|
||||
fn a_template_needs_at_least_one_column() {
|
||||
assert!(
|
||||
template_selection(Locale::default(), &ImportForm::default(), &destination()).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// The ignored positions are labelled with the source's own names when the
|
||||
/// file gave any, which is what makes the summary readable.
|
||||
#[test]
|
||||
fn a_header_files_names_are_read_back_for_the_summary() {
|
||||
let form = ImportForm {
|
||||
source_mode: "header".to_string(),
|
||||
csv_data: "\"Company\",\"Internal note\"\n\"Acme\",\"old\"\n".to_string(),
|
||||
..ImportForm::default()
|
||||
};
|
||||
assert_eq!(
|
||||
source_names(Locale::default(), &form),
|
||||
strings(&["Company", "Internal note"])
|
||||
);
|
||||
|
||||
// A data-only file has none, and the summary falls back to positions.
|
||||
let form = ImportForm {
|
||||
source_mode: "data".to_string(),
|
||||
..form
|
||||
};
|
||||
assert!(source_names(Locale::default(), &form).is_empty());
|
||||
assert!(options[3].label.contains('4'), "{}", options[3].label);
|
||||
assert_eq!(options[3].example, "value-b");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod destination;
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod prepare;
|
||||
@@ -24,6 +25,5 @@ pub(crate) fn router() -> Router<AppState> {
|
||||
// to look at first. Both read the same preparation.
|
||||
.route("/admin/import", post(logic::import_csv))
|
||||
.route("/admin/import/prepared.csv", post(logic::download_prepared))
|
||||
.route("/admin/import/template.csv", post(logic::download_template))
|
||||
.layer(DefaultBodyLimit::max(128 * 1024 * 1024))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
//! Turning what the user uploaded into the one CSV the import understands.
|
||||
//! Turning the uploaded file plus the user's mapping into the one CSV the
|
||||
//! import understands.
|
||||
//!
|
||||
//! Everything here is about *position*. A value lands in a column because the
|
||||
//! user pointed position 3 at `active`, and for no other reason — not because
|
||||
//! a source header spells something similar, not because the words look alike.
|
||||
//! The source's own header, when it has one, is read out for orientation and
|
||||
//! then thrown away.
|
||||
//! There is one shape of input: a header row, then data rows. The header is
|
||||
//! read so the user can tell the file's columns apart, and is then done with —
|
||||
//! it is never imported, and it never decides anything. What decides is the
|
||||
//! 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
|
||||
//! two columns called `name`; the page shows them as `name — position 1` and
|
||||
//! `name — position 2`, and what it stores is 1 and 2.
|
||||
//!
|
||||
//! The output is the canonical form: fully quoted CSV whose header names real
|
||||
//! destination columns, in the order the source presents them. From there the
|
||||
//! import is the strict one it always was — types, validations, scripts, links
|
||||
//! and permissions are all the server's, unchanged.
|
||||
//! destination columns. From there the import is the strict one it always was —
|
||||
//! types, validations, scripts, links and permissions are all the server's.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -17,210 +21,128 @@ use crate::{i18n::Locale, tr};
|
||||
|
||||
use super::super::common::csv::{parse_csv, write_record};
|
||||
|
||||
/// How the uploaded text is laid out.
|
||||
///
|
||||
/// Always the user's answer, never inferred. A data row can hold words that
|
||||
/// read exactly like column names — `"name","num"` is a perfectly good pair of
|
||||
/// customer records — so deciding for them is how a real row gets silently
|
||||
/// eaten as a header.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum SourceMode {
|
||||
/// The first row is the source's own header. It labels the mapping rows
|
||||
/// and is not imported.
|
||||
#[default]
|
||||
Header,
|
||||
/// Every row is data.
|
||||
Data,
|
||||
/// No source at all: pick and order destination columns, and take away a
|
||||
/// header to fill in elsewhere.
|
||||
Template,
|
||||
}
|
||||
|
||||
impl SourceMode {
|
||||
pub(crate) fn parse(locale: Locale, raw: &str) -> Result<Self, String> {
|
||||
match raw {
|
||||
"header" => Ok(Self::Header),
|
||||
"data" => Ok(Self::Data),
|
||||
"template" => Ok(Self::Template),
|
||||
_ => Err(tr!(locale, "import-err-source-mode")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Header => "header",
|
||||
Self::Data => "data",
|
||||
Self::Template => "template",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a source file is needed at all. A template is generated from the
|
||||
/// table, so there is nothing to upload.
|
||||
pub(crate) fn reads_a_file(self) -> bool {
|
||||
!matches!(self, Self::Template)
|
||||
}
|
||||
}
|
||||
|
||||
/// The source file, split into the header it states and the rows that carry
|
||||
/// values.
|
||||
/// The uploaded file: its header, and its data.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Source {
|
||||
/// The source's own header row, kept only to label the mapping rows. It is
|
||||
/// `None` for a data-only file, and it never decides anything.
|
||||
pub header: Option<Vec<String>>,
|
||||
/// The names the file gives its own columns. Shown to the user so they can
|
||||
/// recognise the file; never matched against anything.
|
||||
pub header: Vec<String>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
/// How many positions every row has. The mapping has exactly this many
|
||||
/// entries, which is what makes "position 3" mean one thing.
|
||||
pub width: usize,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// What the first data row holds at each position, for the mapping step to
|
||||
/// show beside the destination picker. An example is the one thing that
|
||||
/// reliably tells a user which column they are looking at.
|
||||
pub(crate) fn examples(&self) -> Vec<String> {
|
||||
(0..self.width)
|
||||
.map(|position| {
|
||||
self.rows
|
||||
.first()
|
||||
.and_then(|row| row.get(position))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
/// How many positions the file has. Every row has exactly these, which is
|
||||
/// what makes "position 3" mean one thing.
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.header.len()
|
||||
}
|
||||
|
||||
/// The source's name for a position, when it has one.
|
||||
pub(crate) fn source_name(&self, position: usize) -> Option<String> {
|
||||
self.header
|
||||
.as_ref()
|
||||
.and_then(|header| header.get(position))
|
||||
.cloned()
|
||||
/// The name the file gives position `index`, empty when it gave none.
|
||||
pub(crate) fn name(&self, index: usize) -> &str {
|
||||
self.header.get(index).map(String::as_str).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What the first data row holds at `index`. An example is the fastest way
|
||||
/// to tell which column of a file you are looking at.
|
||||
pub(crate) fn example(&self, index: usize) -> &str {
|
||||
self.rows
|
||||
.first()
|
||||
.and_then(|row| row.get(index))
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the uploaded text as `mode` says it is laid out.
|
||||
pub(crate) fn read_source(locale: Locale, csv: &str, mode: SourceMode) -> Result<Source, String> {
|
||||
/// Reads the uploaded text: header row, then at least one data row, every row
|
||||
/// the same width.
|
||||
pub(crate) fn read_source(locale: Locale, csv: &str) -> Result<Source, String> {
|
||||
let mut rows = parse_csv(locale, csv)?;
|
||||
let header = match mode {
|
||||
SourceMode::Header => {
|
||||
if rows.len() < 2 {
|
||||
return Err(tr!(locale, "import-err-no-data-rows"));
|
||||
}
|
||||
Some(rows.remove(0))
|
||||
}
|
||||
SourceMode::Data => None,
|
||||
// Nothing to read: the caller decides the columns from the table.
|
||||
SourceMode::Template => return Err(tr!(locale, "import-err-template-has-no-source")),
|
||||
};
|
||||
if rows.is_empty() {
|
||||
// One row is a header with nothing under it, which is not an import.
|
||||
if rows.len() < 2 {
|
||||
return Err(tr!(locale, "import-err-no-data-rows"));
|
||||
}
|
||||
|
||||
let width = header
|
||||
.as_ref()
|
||||
.map_or_else(|| rows[0].len(), |header| header.len());
|
||||
if width == 0 {
|
||||
let header = rows.remove(0);
|
||||
if header.is_empty() {
|
||||
return Err(tr!(locale, "import-err-empty"));
|
||||
}
|
||||
// Positions only mean anything if every row has the same ones. A short row
|
||||
// would otherwise shift every value after the gap into the wrong column.
|
||||
if let Some(row) = rows.iter().find(|row| row.len() != width) {
|
||||
if let Some(row) = rows.iter().find(|row| row.len() != header.len()) {
|
||||
return Err(tr!(
|
||||
locale,
|
||||
"import-err-row-width",
|
||||
"expected" => width as i64,
|
||||
"expected" => header.len() as i64,
|
||||
"found" => row.len() as i64,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Source { header, rows, width })
|
||||
Ok(Source { header, rows })
|
||||
}
|
||||
|
||||
/// Where each source position is to be written, `None` being "ignore".
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Mapping(Vec<Option<String>>);
|
||||
|
||||
impl Mapping {
|
||||
/// The destination chosen for a position, for the form to re-render with
|
||||
/// the user's own answers still selected.
|
||||
pub(crate) fn target(&self, position: usize) -> Option<&str> {
|
||||
self.0.get(position).and_then(Option::as_deref)
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
/// One destination column, and the source position its values come from.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct Assignment {
|
||||
/// The destination's current name, which is what the prepared header says.
|
||||
pub column: String,
|
||||
/// Index into a source row, from 0. The page numbers positions from 1.
|
||||
pub source: usize,
|
||||
}
|
||||
|
||||
/// Reads the posted mapping and checks the rules that make "proper data in the
|
||||
/// proper place" true rather than hoped for.
|
||||
/// Checks the mapping the user built and reduces it to the assignments that
|
||||
/// actually write something.
|
||||
///
|
||||
/// `writable` is the destination list the table itself declares, loaded again
|
||||
/// on every step: a column dropped or renamed while the form was open is caught
|
||||
/// here instead of writing values into a column that no longer means what the
|
||||
/// user chose.
|
||||
/// `chosen` is one entry per writable destination column, in the order the
|
||||
/// mapping step rendered them, `None` meaning "do not import". A destination
|
||||
/// cannot appear twice because it only ever has one row, so what is left to
|
||||
/// check is the source side.
|
||||
pub(crate) fn read_mapping(
|
||||
locale: Locale,
|
||||
posted: &[String],
|
||||
width: usize,
|
||||
writable: &[String],
|
||||
) -> Result<Mapping, String> {
|
||||
// One select per position, always posted, so the form's shape and the
|
||||
// file's have to agree. They will not if the CSV was edited after the
|
||||
// mapping was built.
|
||||
if posted.len() != width {
|
||||
return Err(tr!(
|
||||
locale,
|
||||
"import-err-mapping-stale",
|
||||
"positions" => width as i64,
|
||||
"mapped" => posted.len() as i64,
|
||||
));
|
||||
}
|
||||
|
||||
let known = writable.iter().map(String::as_str).collect::<HashSet<_>>();
|
||||
chosen: &[(String, Option<usize>)],
|
||||
source: &Source,
|
||||
) -> Result<Vec<Assignment>, String> {
|
||||
let mut used = HashSet::new();
|
||||
let mut targets = Vec::with_capacity(width);
|
||||
for (position, target) in posted.iter().enumerate() {
|
||||
let target = target.trim();
|
||||
if target.is_empty() {
|
||||
targets.push(None);
|
||||
continue;
|
||||
}
|
||||
if !known.contains(target) {
|
||||
let mut assignments = Vec::new();
|
||||
for (column, position) in chosen {
|
||||
let Some(index) = position else { continue };
|
||||
// A position the file does not have — the CSV was edited after the
|
||||
// mapping was built, or the form was tampered with.
|
||||
if *index >= source.width() {
|
||||
return Err(tr!(
|
||||
locale,
|
||||
"import-err-mapping-unknown-column",
|
||||
"column" => target.to_string(),
|
||||
"position" => (position + 1) as i64,
|
||||
"import-err-source-position-missing",
|
||||
"position" => (*index + 1) as i64,
|
||||
"column" => column.clone(),
|
||||
"positions" => source.width() as i64,
|
||||
));
|
||||
}
|
||||
// Two positions pointed at one column let whichever is read second
|
||||
// decide the value, silently. Refused instead.
|
||||
if !used.insert(target.to_string()) {
|
||||
// One source column feeding two destinations is more often a misclick
|
||||
// than an intention, and the page stops offering a position once it is
|
||||
// taken. Saying so beats copying a value into two columns quietly.
|
||||
if !used.insert(*index) {
|
||||
return Err(tr!(
|
||||
locale,
|
||||
"import-err-mapping-duplicate",
|
||||
"column" => target.to_string(),
|
||||
"import-err-source-used-twice",
|
||||
"position" => (*index + 1) as i64,
|
||||
"name" => source.name(*index).to_string(),
|
||||
));
|
||||
}
|
||||
targets.push(Some(target.to_string()));
|
||||
assignments.push(Assignment {
|
||||
column: column.clone(),
|
||||
source: *index,
|
||||
});
|
||||
}
|
||||
if used.is_empty() {
|
||||
if assignments.is_empty() {
|
||||
return Err(tr!(locale, "import-err-mapping-empty"));
|
||||
}
|
||||
Ok(Mapping(targets))
|
||||
Ok(assignments)
|
||||
}
|
||||
|
||||
/// The canonical import, and what it leaves behind.
|
||||
pub(crate) struct Prepared {
|
||||
/// The destination header, in source-position order — which is the only
|
||||
/// order the data can be written in, since the values arrive in it.
|
||||
/// The destination header, in the order the mapping step lists the columns.
|
||||
pub columns: Vec<String>,
|
||||
/// Data rows holding only the mapped positions, aligned to `columns`.
|
||||
/// Data rows holding only the mapped values, aligned to `columns`.
|
||||
pub rows: Vec<Vec<String>>,
|
||||
/// The source positions sent nowhere, numbered from 1 as the mapping step
|
||||
/// numbers them.
|
||||
/// The source positions no destination takes, from 1.
|
||||
pub ignored: Vec<usize>,
|
||||
/// Destination columns this import does not write. The server fills them
|
||||
/// with their defaults, or refuses the row if it cannot.
|
||||
@@ -232,8 +154,8 @@ impl Prepared {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// The first `limit` rows, for the preview. A preview is for recognising
|
||||
/// your own data, and nobody recognises it on row 40 000.
|
||||
/// The first `limit` rows. A preview is for recognising your own data, and
|
||||
/// nobody recognises it on row 40 000.
|
||||
pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec<String>] {
|
||||
&self.rows[..self.rows.len().min(limit)]
|
||||
}
|
||||
@@ -243,42 +165,47 @@ impl Prepared {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the mapping: the values that were pointed somewhere, under the names
|
||||
/// they were pointed at.
|
||||
pub(crate) fn prepare(mapping: &Mapping, source: &Source, writable: &[String]) -> Prepared {
|
||||
let taken = (0..mapping.len())
|
||||
.filter_map(|position| Some((position, mapping.target(position)?.to_string())))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let columns = taken
|
||||
/// Applies the mapping: the values that were asked for, under the names they
|
||||
/// were asked for.
|
||||
///
|
||||
/// `writable` is every destination column the table offers, so the result can
|
||||
/// say which ones this import leaves alone.
|
||||
pub(crate) fn prepare(
|
||||
assignments: &[Assignment],
|
||||
source: &Source,
|
||||
writable: &[String],
|
||||
) -> Prepared {
|
||||
let columns = assignments
|
||||
.iter()
|
||||
.map(|(_, column)| column.clone())
|
||||
.map(|assignment| assignment.column.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let rows = source
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
taken
|
||||
assignments
|
||||
.iter()
|
||||
.map(|(position, _)| row.get(*position).cloned().unwrap_or_default())
|
||||
.map(|assignment| row.get(assignment.source).cloned().unwrap_or_default())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let ignored = (0..source.width)
|
||||
.filter(|position| mapping.target(*position).is_none())
|
||||
.map(|position| position + 1)
|
||||
.collect();
|
||||
let omitted = writable
|
||||
let taken = assignments
|
||||
.iter()
|
||||
.filter(|column| !columns.contains(column))
|
||||
.cloned()
|
||||
.collect();
|
||||
.map(|assignment| assignment.source)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
Prepared {
|
||||
ignored: (0..source.width())
|
||||
.filter(|index| !taken.contains(index))
|
||||
.map(|index| index + 1)
|
||||
.collect(),
|
||||
omitted: writable
|
||||
.iter()
|
||||
.filter(|column| !columns.contains(column))
|
||||
.cloned()
|
||||
.collect(),
|
||||
columns,
|
||||
rows,
|
||||
ignored,
|
||||
omitted,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,39 +221,6 @@ pub(crate) fn canonical_csv(prepared: &Prepared) -> String {
|
||||
csv
|
||||
}
|
||||
|
||||
/// A header and nothing else: the columns the user picked, in the order they
|
||||
/// arranged them, ready to be filled in elsewhere and brought back as a file
|
||||
/// with a header.
|
||||
pub(crate) fn template_csv(columns: &[String]) -> String {
|
||||
let mut csv = String::new();
|
||||
write_record(&mut csv, columns);
|
||||
csv
|
||||
}
|
||||
|
||||
/// The destination a position starts out pointed at when the mapping step first
|
||||
/// opens.
|
||||
///
|
||||
/// Only an exact match counts, and only against a real destination column. That
|
||||
/// is not name interpretation: a header cell that *is* the column's name is the
|
||||
/// column's name, which is the case whenever the file came from this system's
|
||||
/// own template or export. Anything else starts at "Ignore", and every row is
|
||||
/// in front of the user to confirm or change before a single value moves.
|
||||
pub(crate) fn suggest_mapping(source: &Source, writable: &[String]) -> Vec<String> {
|
||||
let mut used = HashSet::new();
|
||||
(0..source.width)
|
||||
.map(|position| {
|
||||
let Some(name) = source.source_name(position) else {
|
||||
return String::new();
|
||||
};
|
||||
if writable.contains(&name) && used.insert(name.clone()) {
|
||||
name
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -335,101 +229,94 @@ mod tests {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
}
|
||||
|
||||
fn writable() -> Vec<String> {
|
||||
strings(&["name", "company_number", "active", "note"])
|
||||
/// The worked example: six source columns, three of them wanted.
|
||||
fn example() -> Source {
|
||||
read_source(
|
||||
Locale::default(),
|
||||
"\"1\",\"1b\",\"hl\",\"he\",\"s\",\"aa\"\n\
|
||||
\"x1\",\"x2\",\"value-a\",\"value-b\",\"value-c\",\"unused\"\n\
|
||||
\"y1\",\"y2\",\"other-a\",\"other-b\",\"other-c\",\"unused\"\n",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A data-only file is all rows, and nothing in it is read as a header —
|
||||
/// even when the first row happens to hold the column names.
|
||||
#[test]
|
||||
fn a_data_only_file_keeps_its_first_row() {
|
||||
let csv = "\"name\",\"num\"\n\"Acme\",\"12345678\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
|
||||
assert!(source.header.is_none());
|
||||
fn the_first_row_is_the_header_and_the_rest_is_data() {
|
||||
let source = example();
|
||||
assert_eq!(source.width(), 6);
|
||||
assert_eq!(source.rows.len(), 2);
|
||||
assert_eq!(source.examples(), strings(&["name", "num"]));
|
||||
assert_eq!(source.name(2), "hl");
|
||||
assert_eq!(source.example(2), "value-a");
|
||||
}
|
||||
|
||||
/// A file declared to have a header loses its first row, and that row is
|
||||
/// only ever shown back to the user.
|
||||
#[test]
|
||||
fn a_header_file_loses_its_first_row_to_labels() {
|
||||
let csv = "\"Company\",\"Registration number\"\n\"Acme\",\"12345678\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
|
||||
assert_eq!(source.rows.len(), 1);
|
||||
assert_eq!(source.source_name(0).as_deref(), Some("Company"));
|
||||
assert_eq!(source.examples(), strings(&["Acme", "12345678"]));
|
||||
}
|
||||
|
||||
/// A header with no rows under it is not an import, and says so rather than
|
||||
/// A header with nothing under it is not an import, and says so rather than
|
||||
/// preparing an empty one.
|
||||
#[test]
|
||||
fn a_header_with_nothing_under_it_is_refused() {
|
||||
let csv = "\"Company\",\"Registration number\"\n";
|
||||
assert!(read_source(Locale::default(), csv, SourceMode::Header).is_err());
|
||||
assert!(read_source(Locale::default(), "\"a\",\"b\"\n").is_err());
|
||||
}
|
||||
|
||||
/// Positions only mean something if every row has the same ones.
|
||||
#[test]
|
||||
fn rows_of_different_widths_are_refused() {
|
||||
let csv = "\"Acme\",\"12345678\"\n\"Example\"\n";
|
||||
let error = read_source(Locale::default(), csv, SourceMode::Data)
|
||||
fn rows_of_a_different_width_than_the_header_are_refused() {
|
||||
let error = read_source(Locale::default(), "\"a\",\"b\"\n\"only-one\"\n")
|
||||
.expect_err("a short row shifts every value after it");
|
||||
assert!(error.contains('2') && error.contains('1'), "{error}");
|
||||
}
|
||||
|
||||
/// The heart of it: the user's choices decide where values go, source names
|
||||
/// are irrelevant, and an ignored position takes its value nowhere.
|
||||
/// The whole point, end to end: three destinations take three positions,
|
||||
/// the other three source columns go nowhere.
|
||||
#[test]
|
||||
fn values_land_where_the_user_pointed_them() {
|
||||
let csv = "\"whatever\",\"old field 7\",\"enabled value\",\"legacy\"\n\
|
||||
\"Acme\",\"12345678\",\"true\",\"drop me\"\n\
|
||||
\"Example\",\"87654321\",\"false\",\"drop me too\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
|
||||
let mapping = read_mapping(
|
||||
fn only_the_mapped_destinations_are_prepared() {
|
||||
let source = example();
|
||||
let writable = strings(&["a", "b", "c", "note"]);
|
||||
let assignments = read_mapping(
|
||||
Locale::default(),
|
||||
&strings(&["name", "company_number", "active", ""]),
|
||||
source.width,
|
||||
&writable(),
|
||||
&[
|
||||
("a".to_string(), Some(2)),
|
||||
("b".to_string(), Some(3)),
|
||||
("c".to_string(), Some(4)),
|
||||
("note".to_string(), None),
|
||||
],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&mapping, &source, &writable());
|
||||
let prepared = prepare(&assignments, &source, &writable);
|
||||
|
||||
assert_eq!(
|
||||
prepared.columns,
|
||||
strings(&["name", "company_number", "active"])
|
||||
);
|
||||
assert_eq!(prepared.rows[0], strings(&["Acme", "12345678", "true"]));
|
||||
assert_eq!(prepared.ignored, vec![4]);
|
||||
assert_eq!(prepared.columns, strings(&["a", "b", "c"]));
|
||||
assert_eq!(prepared.ignored, vec![1, 2, 6]);
|
||||
assert_eq!(prepared.omitted, strings(&["note"]));
|
||||
assert_eq!(
|
||||
canonical_csv(&prepared),
|
||||
"\"name\",\"company_number\",\"active\"\n\
|
||||
\"Acme\",\"12345678\",\"true\"\n\
|
||||
\"Example\",\"87654321\",\"false\"\n"
|
||||
"\"a\",\"b\",\"c\"\n\
|
||||
\"value-a\",\"value-b\",\"value-c\"\n\
|
||||
\"other-a\",\"other-b\",\"other-c\"\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-order and sparse mappings are the normal case, not an edge one:
|
||||
/// the header follows the source's positions, because the values do.
|
||||
/// Destinations may take their values in any order, and out of order is the
|
||||
/// normal case — the prepared header follows the destination rows, and each
|
||||
/// row's values follow it.
|
||||
#[test]
|
||||
fn the_prepared_header_follows_the_source_order() {
|
||||
let csv = "\"Acme\",\"unused legacy value\",\"true\",\"12345678\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
|
||||
let mapping = read_mapping(
|
||||
fn a_destination_may_take_any_position() {
|
||||
let source = example();
|
||||
let assignments = read_mapping(
|
||||
Locale::default(),
|
||||
&strings(&["name", "", "active", "company_number"]),
|
||||
source.width,
|
||||
&writable(),
|
||||
&[
|
||||
("a".to_string(), Some(5)),
|
||||
("b".to_string(), None),
|
||||
("c".to_string(), Some(0)),
|
||||
],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&mapping, &source, &writable());
|
||||
let prepared = prepare(&assignments, &source, &strings(&["a", "b", "c"]));
|
||||
|
||||
assert_eq!(
|
||||
canonical_csv(&prepared),
|
||||
"\"name\",\"active\",\"company_number\"\n\"Acme\",\"true\",\"12345678\"\n"
|
||||
"\"a\",\"c\"\n\"unused\",\"x1\"\n\"unused\",\"y1\"\n"
|
||||
);
|
||||
assert_eq!(prepared.ignored, vec![2]);
|
||||
assert_eq!(prepared.omitted, strings(&["b"]));
|
||||
}
|
||||
|
||||
/// What is downloaded and what is imported are the same file. The import
|
||||
@@ -437,129 +324,79 @@ mod tests {
|
||||
/// that the two cannot drift apart.
|
||||
#[test]
|
||||
fn the_downloaded_csv_parses_back_to_the_rows_that_get_imported() {
|
||||
let csv = "\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
|
||||
let mapping = read_mapping(
|
||||
let source = read_source(
|
||||
Locale::default(),
|
||||
&strings(&["name", "note"]),
|
||||
source.width,
|
||||
&writable(),
|
||||
"\"name\",\"note\"\n\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&mapping, &source, &writable());
|
||||
let assignments = read_mapping(
|
||||
Locale::default(),
|
||||
&[("name".to_string(), Some(0)), ("note".to_string(), Some(1))],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&assignments, &source, &strings(&["name", "note"]));
|
||||
|
||||
let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap();
|
||||
assert_eq!(reparsed[0], prepared.columns);
|
||||
assert_eq!(reparsed[1..], prepared.rows[..]);
|
||||
}
|
||||
|
||||
/// One destination cannot be filled from two positions: the second would
|
||||
/// silently win.
|
||||
/// A file may name two columns the same. Nothing here cares: the mapping is
|
||||
/// positions, and the two are 1 and 2.
|
||||
#[test]
|
||||
fn a_destination_cannot_be_chosen_twice() {
|
||||
let error = read_mapping(
|
||||
fn duplicate_source_names_are_not_ambiguous() {
|
||||
let source = read_source(
|
||||
Locale::default(),
|
||||
&strings(&["name", "name"]),
|
||||
2,
|
||||
&writable(),
|
||||
)
|
||||
.expect_err("two positions cannot both be `name`");
|
||||
assert!(error.contains("name"), "{error}");
|
||||
}
|
||||
|
||||
/// A destination that is not a writable column of this table is refused,
|
||||
/// whatever the form posted — the table is asked again on every step.
|
||||
#[test]
|
||||
fn a_destination_the_table_does_not_offer_is_refused() {
|
||||
let error = read_mapping(
|
||||
Locale::default(),
|
||||
&strings(&["name", "id"]),
|
||||
2,
|
||||
&writable(),
|
||||
)
|
||||
.expect_err("`id` is the server's");
|
||||
assert!(error.contains("id"), "{error}");
|
||||
}
|
||||
|
||||
/// A mapping built against a different file is refused rather than applied
|
||||
/// to whatever positions happen to line up.
|
||||
#[test]
|
||||
fn a_mapping_that_does_not_cover_the_file_is_refused() {
|
||||
assert!(read_mapping(Locale::default(), &strings(&["name"]), 3, &writable()).is_err());
|
||||
}
|
||||
|
||||
/// Ignoring everything is not an import.
|
||||
#[test]
|
||||
fn a_mapping_that_writes_nothing_is_refused() {
|
||||
assert!(read_mapping(Locale::default(), &strings(&["", ""]), 2, &writable()).is_err());
|
||||
}
|
||||
|
||||
/// A file this system generated the template for comes back with its own
|
||||
/// column names, so every row starts out pointed at the right place — still
|
||||
/// shown, still confirmed, never applied on its own.
|
||||
#[test]
|
||||
fn an_exact_header_starts_the_mapping_off() {
|
||||
let csv = "\"name\",\"active\"\n\"Acme\",\"true\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
|
||||
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", "active"]));
|
||||
}
|
||||
|
||||
/// Anything that is not exactly a column name starts at "Ignore". There is
|
||||
/// no folding, no trimming and no similarity: ` name` and `Name` are not
|
||||
/// the column `name`, and "Company" is not a guess anyone should make.
|
||||
#[test]
|
||||
fn nothing_but_an_exact_name_is_suggested() {
|
||||
let csv = "\"Company\",\" name\",\"NAME\",\"note\"\n\"Acme\",\"x\",\"y\",\"z\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
|
||||
assert_eq!(
|
||||
suggest_mapping(&source, &writable()),
|
||||
strings(&["", "", "", "note"])
|
||||
);
|
||||
}
|
||||
|
||||
/// A header naming one column twice suggests it once: the second would be
|
||||
/// a duplicate the mapping step refuses anyway.
|
||||
#[test]
|
||||
fn a_repeated_header_is_only_suggested_once() {
|
||||
let csv = "\"name\",\"name\"\n\"Acme\",\"Other\"\n";
|
||||
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
|
||||
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", ""]));
|
||||
}
|
||||
|
||||
/// The generated template is a header and nothing else, in the order it was
|
||||
/// arranged in.
|
||||
#[test]
|
||||
fn a_template_is_the_chosen_columns_in_the_chosen_order() {
|
||||
assert_eq!(
|
||||
template_csv(&strings(&["active", "name", "company_number"])),
|
||||
"\"active\",\"name\",\"company_number\"\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// And it is a file this page can read straight back: the header mode plus
|
||||
/// the exact-name suggestion close the loop.
|
||||
#[test]
|
||||
fn a_generated_template_comes_back_ready_to_import() {
|
||||
let mut csv = template_csv(&strings(&["name", "active"]));
|
||||
csv.push_str("\"Acme\",\"true\"\n");
|
||||
let source = read_source(Locale::default(), &csv, SourceMode::Header).unwrap();
|
||||
let mapping = read_mapping(
|
||||
Locale::default(),
|
||||
&suggest_mapping(&source, &writable()),
|
||||
source.width,
|
||||
&writable(),
|
||||
"\"name\",\"name\",\"status\"\n\"first\",\"second\",\"live\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&mapping, &source, &writable());
|
||||
assert_eq!(prepared.columns, strings(&["name", "active"]));
|
||||
assert_eq!(prepared.rows, vec![strings(&["Acme", "true"])]);
|
||||
let assignments = read_mapping(
|
||||
Locale::default(),
|
||||
&[("a".to_string(), Some(1)), ("b".to_string(), Some(0))],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let prepared = prepare(&assignments, &source, &strings(&["a", "b"]));
|
||||
|
||||
assert_eq!(prepared.rows, vec![strings(&["second", "first"])]);
|
||||
}
|
||||
|
||||
/// One source column feeding two destinations is refused: the page stops
|
||||
/// offering a position once it is taken, so arriving here means something
|
||||
/// went wrong rather than that the user meant it.
|
||||
#[test]
|
||||
fn a_source_mode_round_trips_and_an_unknown_one_is_refused() {
|
||||
for mode in [SourceMode::Header, SourceMode::Data, SourceMode::Template] {
|
||||
assert_eq!(SourceMode::parse(Locale::default(), mode.as_str()), Ok(mode));
|
||||
}
|
||||
assert!(SourceMode::parse(Locale::default(), "guess").is_err());
|
||||
fn a_source_position_cannot_be_used_twice() {
|
||||
let source = example();
|
||||
let error = read_mapping(
|
||||
Locale::default(),
|
||||
&[("a".to_string(), Some(2)), ("b".to_string(), Some(2))],
|
||||
&source,
|
||||
)
|
||||
.expect_err("position 3 cannot fill both");
|
||||
assert!(error.contains("hl") && error.contains('3'), "{error}");
|
||||
}
|
||||
|
||||
/// A position the file does not have is refused rather than read as blank.
|
||||
#[test]
|
||||
fn a_position_past_the_end_of_the_file_is_refused() {
|
||||
let source = example();
|
||||
let error = read_mapping(Locale::default(), &[("a".to_string(), Some(9))], &source)
|
||||
.expect_err("the file has six positions");
|
||||
assert!(error.contains("10") && error.contains('6'), "{error}");
|
||||
}
|
||||
|
||||
/// Mapping nothing is not an import.
|
||||
#[test]
|
||||
fn a_mapping_that_writes_nothing_is_refused() {
|
||||
let source = example();
|
||||
assert!(
|
||||
read_mapping(
|
||||
Locale::default(),
|
||||
&[("a".to_string(), None), ("b".to_string(), None)],
|
||||
&source
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,37 @@
|
||||
use crate::{i18n::Locale, tr};
|
||||
|
||||
use super::prepare::SourceMode;
|
||||
|
||||
/// Every field the import form carries, at every step.
|
||||
///
|
||||
/// The whole preparation is one form posted back and forth: the page keeps no
|
||||
/// server-side session, so each step re-renders the fields the next one needs
|
||||
/// and the user can go back without losing what they chose.
|
||||
/// server-side draft, so each step re-states the answers the next one needs and
|
||||
/// the user can go back without losing what they chose.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct ImportForm {
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
/// One table. An import writes into one table, and a file for two tables is
|
||||
/// two prepared imports rather than one file with a second header row.
|
||||
/// two prepared imports.
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
/// `header`, `data` or `template` — see [`SourceMode`]. Kept as text so an
|
||||
/// unrecognised value is a message rather than a form rejection.
|
||||
#[serde(default)]
|
||||
pub source_mode: String,
|
||||
/// The uploaded file, header row and all.
|
||||
#[serde(default)]
|
||||
pub csv_data: String,
|
||||
/// The destination chosen for each source position, in position order, one
|
||||
/// entry per position; empty means "ignore". Posted once per `<select>`,
|
||||
/// which only `axum_extra`'s `Form` decodes into a `Vec`.
|
||||
/// One entry per writable destination column, in the order the mapping step
|
||||
/// renders them: the column's stable identity, not its name. See
|
||||
/// [`DestinationKey`](super::destination::DestinationKey).
|
||||
///
|
||||
/// Posted once per row, which only `axum_extra`'s `Form` decodes into a
|
||||
/// `Vec`.
|
||||
#[serde(default)]
|
||||
pub mapping: Vec<String>,
|
||||
/// Every destination column in the order the template step has them
|
||||
/// arranged, chosen or not, so the arrangement survives a re-render.
|
||||
pub destination: Vec<String>,
|
||||
/// The source position chosen for the destination at the same index, from
|
||||
/// 1; empty means "do not import". The two vectors are filled in document
|
||||
/// order, so they line up row for row.
|
||||
#[serde(default)]
|
||||
pub template_order: Vec<String>,
|
||||
/// The columns ticked in the template step, which the checkboxes post in
|
||||
/// the arranged order — so this *is* the generated header.
|
||||
#[serde(default)]
|
||||
pub template_columns: Vec<String>,
|
||||
/// `up` or `down` when a move button posted, with `index` naming the row.
|
||||
#[serde(default)]
|
||||
pub action: Option<String>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
pub source_position: Vec<String>,
|
||||
}
|
||||
|
||||
impl ImportForm {
|
||||
pub(crate) fn mode(&self, locale: Locale) -> Result<SourceMode, String> {
|
||||
if self.source_mode.is_empty() {
|
||||
return Ok(SourceMode::default());
|
||||
}
|
||||
SourceMode::parse(locale, &self.source_mode)
|
||||
}
|
||||
|
||||
/// The scope and table this import writes into, checked for being answered
|
||||
/// at all. Whether they are the user's to write into is the catalog's
|
||||
/// answer, and then the backend's.
|
||||
@@ -61,7 +44,7 @@ impl ImportForm {
|
||||
if table.is_empty() {
|
||||
return Err(tr!(locale, "import-err-tables-required"));
|
||||
}
|
||||
if self.mode(locale)?.reads_a_file() && self.csv_data.trim().is_empty() {
|
||||
if self.csv_data.trim().is_empty() {
|
||||
return Err(tr!(locale, "import-err-csv-required"));
|
||||
}
|
||||
Ok((profile.to_string(), table.to_string()))
|
||||
@@ -72,81 +55,80 @@ impl ImportForm {
|
||||
self.table_name == name
|
||||
}
|
||||
|
||||
/// Whether the radio for `mode` is the one selected, for re-rendering the
|
||||
/// choice the user made.
|
||||
pub(crate) fn is_mode(&self, mode: &str) -> bool {
|
||||
if self.source_mode.is_empty() {
|
||||
return mode == SourceMode::default().as_str();
|
||||
}
|
||||
self.source_mode == mode
|
||||
/// The mapping as posted: the destination key of each row, and the source
|
||||
/// position it was given.
|
||||
///
|
||||
/// A row whose position is blank, unparseable or zero is "do not import".
|
||||
/// Positions are one-based on the page and zero-based here, since what they
|
||||
/// index is a row of the file.
|
||||
pub(crate) fn rows(&self) -> Vec<(&str, Option<usize>)> {
|
||||
self.destination
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, key)| {
|
||||
let position = self
|
||||
.source_position
|
||||
.get(index)
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
.and_then(|position| position.checked_sub(1));
|
||||
(key.as_str(), position)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the preparation's steps the page is showing.
|
||||
///
|
||||
/// One page, three stops: say what the source is, say where each position goes,
|
||||
/// then look at the result before anything is written.
|
||||
/// One page, three stops: say where the data is going and hand over the file,
|
||||
/// say where each destination column's value comes from, then look at the
|
||||
/// result before anything is written.
|
||||
pub(crate) enum Step {
|
||||
/// Choose the destination table and describe the source.
|
||||
Source,
|
||||
/// Point each source position at a destination column.
|
||||
Mapping(MappingStep),
|
||||
/// Pick and arrange destination columns, and take away the header.
|
||||
Template(TemplateStep),
|
||||
/// The prepared import, as it will be sent.
|
||||
Preview(PreviewStep),
|
||||
}
|
||||
|
||||
pub(crate) struct MappingStep {
|
||||
pub table_name: String,
|
||||
/// The destinations to offer, in the order the table declares them.
|
||||
pub columns: Vec<String>,
|
||||
/// One row per writable destination column. A destination cannot be filled
|
||||
/// twice because it appears exactly once.
|
||||
pub rows: Vec<MappingRow>,
|
||||
/// The file's columns, as they are offered in every picker.
|
||||
pub sources: Vec<SourceOption>,
|
||||
pub source_rows: usize,
|
||||
/// Whether the source stated a header, so the table can show that column at
|
||||
/// all — and say that it decides nothing.
|
||||
pub has_source_names: bool,
|
||||
}
|
||||
|
||||
/// One source position, and what the user has pointed it at.
|
||||
/// One destination column, asking where its value comes from.
|
||||
pub(crate) struct MappingRow {
|
||||
/// Numbered from 1, the way the source is read by a person.
|
||||
pub position: usize,
|
||||
/// What the source called this position, when it said. Orientation only.
|
||||
pub source_name: Option<String>,
|
||||
/// The first data row's value here — usually the fastest way to recognise
|
||||
/// which column this is.
|
||||
/// The stable identity, for the hidden field.
|
||||
pub key: String,
|
||||
/// The name the table shows, and the name the prepared header will use.
|
||||
pub name: String,
|
||||
pub required: bool,
|
||||
/// The chosen source position as the form carries it — one-based, empty for
|
||||
/// "do not import".
|
||||
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,
|
||||
/// The chosen destination, empty for "ignore".
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl MappingRow {
|
||||
/// Whether this position is pointed at `column`, for re-rendering the
|
||||
/// picker with the user's own answer selected.
|
||||
pub(crate) fn targets(&self, column: &str) -> bool {
|
||||
self.target == column
|
||||
/// 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 {
|
||||
self.chosen == option.position.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TemplateStep {
|
||||
pub table_name: String,
|
||||
pub rows: Vec<TemplateRow>,
|
||||
/// The generated header as it stands, empty when nothing is ticked.
|
||||
pub header: String,
|
||||
pub chosen: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct TemplateRow {
|
||||
/// Position in the arrangement, from 0, for the move buttons.
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
pub chosen: bool,
|
||||
/// Declared `NOT NULL`, so leaving it out of the template is likely to
|
||||
/// produce rows the server refuses.
|
||||
pub required: bool,
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
/// One column of the uploaded file, as the pickers offer it.
|
||||
pub(crate) struct SourceOption {
|
||||
/// One-based, which is how the page counts and how the form posts.
|
||||
pub position: usize,
|
||||
/// `hl — position 3`, or just the position when the file left the name
|
||||
/// blank. Built in Rust so it is translated once.
|
||||
pub label: String,
|
||||
pub example: String,
|
||||
}
|
||||
|
||||
pub(crate) struct PreviewStep {
|
||||
@@ -156,8 +138,8 @@ pub(crate) struct PreviewStep {
|
||||
/// Rows the preview does not show, so a large file says how large.
|
||||
pub hidden_rows: usize,
|
||||
pub source_rows: usize,
|
||||
/// Source positions written nowhere, labelled with the source's own name
|
||||
/// when it gave one.
|
||||
/// The file's columns no destination takes, labelled the way the pickers
|
||||
/// labelled them.
|
||||
pub ignored: Vec<String>,
|
||||
/// Destination columns this import does not write.
|
||||
pub omitted: Vec<String>,
|
||||
@@ -180,35 +162,84 @@ pub(crate) struct ImportPageState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A template needs no file, so the form does not insist on one — and the
|
||||
/// other two modes do.
|
||||
#[test]
|
||||
fn only_a_file_import_needs_a_file() {
|
||||
let form = ImportForm {
|
||||
fn a_target_needs_a_scope_a_table_and_a_file() {
|
||||
let complete = ImportForm {
|
||||
profile_name: "acme".to_string(),
|
||||
table_name: "customers".to_string(),
|
||||
source_mode: "template".to_string(),
|
||||
csv_data: "\"a\"\n\"1\"\n".to_string(),
|
||||
..ImportForm::default()
|
||||
};
|
||||
assert_eq!(
|
||||
form.target(Locale::default()).unwrap(),
|
||||
complete.target(Locale::default()).unwrap(),
|
||||
("acme".to_string(), "customers".to_string())
|
||||
);
|
||||
|
||||
let form = ImportForm {
|
||||
source_mode: "data".to_string(),
|
||||
..form
|
||||
};
|
||||
assert!(form.target(Locale::default()).is_err());
|
||||
for missing in [
|
||||
ImportForm {
|
||||
profile_name: String::new(),
|
||||
..complete.clone()
|
||||
},
|
||||
ImportForm {
|
||||
table_name: String::new(),
|
||||
..complete.clone()
|
||||
},
|
||||
ImportForm {
|
||||
csv_data: " ".to_string(),
|
||||
..complete.clone()
|
||||
},
|
||||
] {
|
||||
assert!(missing.target(Locale::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// The default is a file with a header, and the radio renders as such
|
||||
/// before anything has been posted.
|
||||
/// The two posted vectors line up row for row, and the page's one-based
|
||||
/// positions become the zero-based indexes a row is read with.
|
||||
#[test]
|
||||
fn the_source_mode_defaults_to_a_header_file() {
|
||||
let form = ImportForm::default();
|
||||
assert_eq!(form.mode(Locale::default()).unwrap(), SourceMode::Header);
|
||||
assert!(form.is_mode("header"));
|
||||
assert!(!form.is_mode("data"));
|
||||
fn the_posted_rows_pair_up_and_lose_their_offset() {
|
||||
let form = ImportForm {
|
||||
destination: vec![
|
||||
"id:42".to_string(),
|
||||
"id:57".to_string(),
|
||||
"system:deleted".to_string(),
|
||||
],
|
||||
source_position: vec!["3".to_string(), String::new(), "1".to_string()],
|
||||
..ImportForm::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
form.rows(),
|
||||
vec![
|
||||
("id:42", Some(2)),
|
||||
("id:57", None),
|
||||
("system:deleted", Some(0)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Anything that is not a position is "do not import" rather than a guess.
|
||||
/// Zero is not a position either: the page counts from 1.
|
||||
#[test]
|
||||
fn an_unusable_position_means_do_not_import() {
|
||||
let form = ImportForm {
|
||||
destination: vec!["id:1".to_string(), "id:2".to_string(), "id:3".to_string()],
|
||||
source_position: vec!["0".to_string(), "x".to_string(), "-1".to_string()],
|
||||
..ImportForm::default()
|
||||
};
|
||||
|
||||
assert!(form.rows().iter().all(|(_, position)| position.is_none()));
|
||||
}
|
||||
|
||||
/// A row with no position posted at all is still a row, so the pairing
|
||||
/// cannot slide by one.
|
||||
#[test]
|
||||
fn a_missing_position_does_not_shift_the_rows() {
|
||||
let form = ImportForm {
|
||||
destination: vec!["id:1".to_string(), "id:2".to_string()],
|
||||
source_position: vec!["2".to_string()],
|
||||
..ImportForm::default()
|
||||
};
|
||||
|
||||
assert_eq!(form.rows(), vec![("id:1", Some(1)), ("id:2", None)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ mod tests {
|
||||
use crate::pages::import_export::common::loader::{Catalog, Profile};
|
||||
|
||||
use super::super::state::{
|
||||
ImportForm, MappingRow, MappingStep, PreviewStep, TemplateRow, TemplateStep,
|
||||
ImportForm, MappingRow, MappingStep, PreviewStep, SourceOption,
|
||||
};
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
@@ -99,127 +99,113 @@ mod tests {
|
||||
form: ImportForm {
|
||||
profile_name: "acme".to_string(),
|
||||
table_name: "customers".to_string(),
|
||||
source_mode: "header".to_string(),
|
||||
csv_data: "\"Company\"\n\"Acme\"\n".to_string(),
|
||||
mapping: strings(&["name"]),
|
||||
..ImportForm::default()
|
||||
csv_data: "\"hl\",\"he\"\n\"value-a\",\"value-b\"\n".to_string(),
|
||||
destination: strings(&["id:42", "id:57"]),
|
||||
source_position: strings(&["1", ""]),
|
||||
},
|
||||
step,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first step asks where the data goes and how the source is laid out,
|
||||
/// and asks the second question outright — the page never decides for
|
||||
/// itself whether the first row is a header.
|
||||
fn mapping() -> Step {
|
||||
Step::Mapping(MappingStep {
|
||||
table_name: "customers".to_string(),
|
||||
rows: vec![
|
||||
MappingRow {
|
||||
key: "id:42".to_string(),
|
||||
name: "a".to_string(),
|
||||
required: true,
|
||||
chosen: "1".to_string(),
|
||||
example: "value-a".to_string(),
|
||||
},
|
||||
MappingRow {
|
||||
key: "id:57".to_string(),
|
||||
name: "b".to_string(),
|
||||
required: false,
|
||||
chosen: String::new(),
|
||||
example: String::new(),
|
||||
},
|
||||
],
|
||||
sources: vec![
|
||||
SourceOption {
|
||||
position: 1,
|
||||
label: "hl \u{2014} position 1".to_string(),
|
||||
example: "value-a".to_string(),
|
||||
},
|
||||
SourceOption {
|
||||
position: 2,
|
||||
label: "he \u{2014} position 2".to_string(),
|
||||
example: "value-b".to_string(),
|
||||
},
|
||||
],
|
||||
source_rows: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first step asks for a destination and a file, and nothing else —
|
||||
/// there is one shape of input, so there is nothing to choose about it.
|
||||
#[test]
|
||||
fn the_first_step_asks_how_the_source_is_laid_out() {
|
||||
fn the_first_step_asks_for_a_table_and_a_file() {
|
||||
let html = render_page(&page(Step::Source));
|
||||
|
||||
for mode in ["header", "data", "template"] {
|
||||
assert!(
|
||||
html.contains(&format!(r#"name="source_mode" value="{mode}""#)),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
assert!(html.contains(r#"name="table_name""#), "{html}");
|
||||
assert!(html.contains(r#"name="csv_data""#), "{html}");
|
||||
assert!(!html.contains("source_mode"), "{html}");
|
||||
}
|
||||
|
||||
/// The mapping step is one picker per source position, and every picker
|
||||
/// offers Ignore alongside the table's own columns. The source's own name
|
||||
/// is shown next to it and is not what any option is built from.
|
||||
/// The mapping is read destination-first: one row per column of the table,
|
||||
/// each asking where its value comes from. A destination cannot be filled
|
||||
/// twice because it appears exactly once.
|
||||
#[test]
|
||||
fn the_mapping_step_offers_a_destination_for_every_position() {
|
||||
let html = render_step(&page(Step::Mapping(MappingStep {
|
||||
table_name: "customers".to_string(),
|
||||
columns: strings(&["name", "company_number"]),
|
||||
rows: vec![
|
||||
MappingRow {
|
||||
position: 1,
|
||||
source_name: Some("Company".to_string()),
|
||||
example: "Acme".to_string(),
|
||||
target: "name".to_string(),
|
||||
},
|
||||
MappingRow {
|
||||
position: 2,
|
||||
source_name: Some("Internal note".to_string()),
|
||||
example: "old customer".to_string(),
|
||||
target: String::new(),
|
||||
},
|
||||
],
|
||||
source_rows: 2,
|
||||
has_source_names: true,
|
||||
})));
|
||||
fn the_mapping_step_asks_every_destination_column_once() {
|
||||
let html = render_step(&page(mapping()));
|
||||
|
||||
assert_eq!(html.matches(r#"name="mapping""#).count(), 2);
|
||||
assert!(html.contains(r#"<option value="name" selected>"#), "{html}");
|
||||
assert!(html.contains("Internal note"), "{html}");
|
||||
assert!(html.contains("old customer"), "{html}");
|
||||
// The destination and the source it came from are carried forward, so
|
||||
// going back does not lose the file.
|
||||
assert!(html.contains(r#"name="csv_data""#), "{html}");
|
||||
assert_eq!(html.matches(r#"name="source_position""#).count(), 2);
|
||||
assert_eq!(html.matches(r#"name="destination""#).count(), 2);
|
||||
assert!(html.contains("<code>a</code>"), "{html}");
|
||||
assert!(html.contains("<code>b</code>"), "{html}");
|
||||
// The file's columns are offered by name and position together.
|
||||
assert!(html.contains("hl \u{2014} position 1"), "{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(">Do not import</option>"), "{html}");
|
||||
}
|
||||
|
||||
/// The template step is a chooser with an arrangement, and it shows the
|
||||
/// header it would generate rather than describing it.
|
||||
/// 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]
|
||||
fn the_template_step_shows_the_header_it_generates() {
|
||||
let html = render_step(&page(Step::Template(TemplateStep {
|
||||
table_name: "customers".to_string(),
|
||||
rows: vec![
|
||||
TemplateRow {
|
||||
index: 0,
|
||||
name: "name".to_string(),
|
||||
chosen: true,
|
||||
required: true,
|
||||
first: true,
|
||||
last: false,
|
||||
},
|
||||
TemplateRow {
|
||||
index: 1,
|
||||
name: "note".to_string(),
|
||||
chosen: false,
|
||||
required: false,
|
||||
first: false,
|
||||
last: true,
|
||||
},
|
||||
],
|
||||
header: "\"name\"\n".to_string(),
|
||||
chosen: 1,
|
||||
})));
|
||||
fn a_destination_is_carried_as_its_identity() {
|
||||
let html = render_step(&page(mapping()));
|
||||
|
||||
assert!(html.contains(""name""), "{html}");
|
||||
assert_eq!(html.matches(r#"name="template_order""#).count(), 2);
|
||||
assert!(html.contains(r#"formaction="/admin/import/template.csv""#), "{html}");
|
||||
// The ends of the arrangement have nowhere to move to.
|
||||
assert_eq!(html.matches("disabled").count(), 2);
|
||||
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
|
||||
assert!(!html.contains(r#"name="destination" value="a""#), "{html}");
|
||||
}
|
||||
|
||||
/// The preview shows the values under the columns they will actually be
|
||||
/// written to, says what is being left out, and offers both exits.
|
||||
/// The preview shows the values under the columns they will be written to,
|
||||
/// says what is being left out, and offers both exits.
|
||||
#[test]
|
||||
fn the_preview_shows_the_prepared_import_and_both_ways_out() {
|
||||
let html = render_step(&page(Step::Preview(PreviewStep {
|
||||
table_name: "customers".to_string(),
|
||||
columns: strings(&["name", "active"]),
|
||||
rows: vec![strings(&["Acme", "true"])],
|
||||
columns: strings(&["a", "b", "c"]),
|
||||
rows: vec![strings(&["value-a", "value-b", "value-c"])],
|
||||
hidden_rows: 3,
|
||||
source_rows: 4,
|
||||
ignored: strings(&["2 (Internal note)"]),
|
||||
ignored: strings(&["1 \u{2014} position 1", "aa \u{2014} position 6"]),
|
||||
omitted: strings(&["note"]),
|
||||
missing_required: strings(&["company_number"]),
|
||||
csv: "\"name\",\"active\"\n\"Acme\",\"true\"\n".to_string(),
|
||||
missing_required: strings(&["number"]),
|
||||
csv: "\"a\",\"b\",\"c\"\n".to_string(),
|
||||
})));
|
||||
|
||||
assert!(html.contains("<th>name</th>"), "{html}");
|
||||
assert!(html.contains("<td>Acme</td>"), "{html}");
|
||||
assert!(html.contains("2 (Internal note)"), "{html}");
|
||||
assert!(html.contains("company_number"), "{html}");
|
||||
// Import, and download the very same file.
|
||||
assert!(html.contains("<th>a</th>"), "{html}");
|
||||
assert!(html.contains("<td>value-a</td>"), "{html}");
|
||||
assert!(html.contains("aa \u{2014} position 6"), "{html}");
|
||||
assert!(html.contains("number"), "{html}");
|
||||
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
|
||||
assert!(html.contains(""name","active""), "{html}");
|
||||
assert!(html.contains(""a","b","c""), "{html}");
|
||||
// The mapping travels with it, so the download and the import prepare
|
||||
// the identical file.
|
||||
assert!(html.contains(r#"name="mapping" value="name""#), "{html}");
|
||||
assert!(html.contains(r#"name="destination" value="id:42""#), "{html}");
|
||||
assert!(html.contains(r#"name="source_position" value="1""#), "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user