add table is better now
This commit is contained in:
@@ -1,164 +1,293 @@
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink,
|
||||
};
|
||||
//! Wire format for the builder form, and the page state the templates read.
|
||||
//!
|
||||
//! HTTP is stateless, so the whole draft travels with every interaction: each
|
||||
//! already-added column, link and display column is posted back as a set of
|
||||
//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`)
|
||||
//! decodes the repeats into `Vec`s, which `to_draft` zips back into a
|
||||
//! [`TableDraft`].
|
||||
|
||||
use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft};
|
||||
|
||||
/// The `profile_name` option meaning "create a new profile too".
|
||||
pub(crate) const NEW_PROFILE: &str = "__new__";
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct CreateTableForm {
|
||||
pub(crate) struct BuilderForm {
|
||||
/// Which builder button was pressed. Empty on the initial page load.
|
||||
#[serde(default)]
|
||||
pub action: String,
|
||||
/// Row the action applies to, for the per-row buttons.
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
#[serde(default)]
|
||||
pub profile_name_input: String,
|
||||
#[serde(default)]
|
||||
pub accounting_currency: String,
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: String,
|
||||
#[serde(default)]
|
||||
pub indexed_columns: String,
|
||||
#[serde(default)]
|
||||
pub required_links: String,
|
||||
#[serde(default)]
|
||||
pub optional_links: String,
|
||||
#[serde(default)]
|
||||
pub base_currency: String,
|
||||
|
||||
// The pending column being described in the input panel.
|
||||
#[serde(default)]
|
||||
pub row_display_columns: String,
|
||||
pub column_name_input: String,
|
||||
#[serde(default)]
|
||||
pub column_type_input: String,
|
||||
#[serde(default)]
|
||||
pub temporal_type_input: String,
|
||||
#[serde(default)]
|
||||
pub gtin_type_input: String,
|
||||
#[serde(default)]
|
||||
pub column_indexing_input: String,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger_input: String,
|
||||
#[serde(default)]
|
||||
pub column_rounding_input: String,
|
||||
|
||||
// One entry per already-added column, in order.
|
||||
#[serde(default)]
|
||||
pub column_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_indexed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_rounding: Vec<String>,
|
||||
|
||||
// One entry per link target offered by the profile, in order.
|
||||
#[serde(default)]
|
||||
pub link_tables: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub link_modes: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub row_display_columns: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub form: CreateTableForm,
|
||||
pub error: Option<String>,
|
||||
fn is_yes(value: &str) -> bool {
|
||||
value.trim().eq_ignore_ascii_case("yes")
|
||||
}
|
||||
|
||||
impl CreateTableForm {
|
||||
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> {
|
||||
let profile_name = self.profile_name.trim().to_string();
|
||||
let table_name = self.table_name.trim().to_string();
|
||||
if profile_name.is_empty() {
|
||||
return Err("Select a profile.".to_string());
|
||||
}
|
||||
if table_name.is_empty() {
|
||||
return Err("Enter a table name.".to_string());
|
||||
}
|
||||
impl BuilderForm {
|
||||
pub(crate) fn creating_new_profile(&self) -> bool {
|
||||
self.profile_name == NEW_PROFILE
|
||||
}
|
||||
|
||||
let indexed_columns = comma_separated(&self.indexed_columns);
|
||||
let mut columns = Vec::new();
|
||||
let mut inline_indexes = Vec::new();
|
||||
for (index, line) in self.columns.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.splitn(3, ':');
|
||||
let name = parts.next().unwrap_or_default().trim();
|
||||
let field_type = parts.next().unwrap_or_default().trim();
|
||||
let flags = parts.next().unwrap_or_default();
|
||||
if name.is_empty() || field_type.is_empty() {
|
||||
return Err(format!(
|
||||
"Column line {} must use `name: type`.",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let flags = flags
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if flags.contains(&"indexed") {
|
||||
inline_indexes.push(name.to_string());
|
||||
}
|
||||
let rounding = if flags.contains(&"half-up") {
|
||||
MoneyRounding::HalfUp
|
||||
} else {
|
||||
MoneyRounding::None
|
||||
};
|
||||
columns.push(ColumnDefinition {
|
||||
name: name.to_string(),
|
||||
field_type: field_type.to_string(),
|
||||
rounding: rounding.into(),
|
||||
quantity_ledger: flags.contains(&"quantity-ledger"),
|
||||
});
|
||||
}
|
||||
if columns.is_empty() {
|
||||
return Err("Add at least one column.".to_string());
|
||||
}
|
||||
/// Rebuilds the draft this form was rendered from.
|
||||
///
|
||||
/// The column vectors are parallel, so a short one (a truncated or
|
||||
/// tampered-with post) simply limits how many columns are reconstructed
|
||||
/// rather than mis-pairing them.
|
||||
pub(crate) fn to_draft(&self) -> TableDraft {
|
||||
let creating_new_profile = self.creating_new_profile();
|
||||
|
||||
let mut indexes = indexed_columns;
|
||||
for name in inline_indexes {
|
||||
if !indexes.contains(&name) {
|
||||
indexes.push(name);
|
||||
}
|
||||
}
|
||||
let column_count = [
|
||||
self.column_names.len(),
|
||||
self.column_types.len(),
|
||||
self.column_indexed.len(),
|
||||
self.column_quantity_ledger.len(),
|
||||
self.column_rounding.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut links = comma_separated(&self.required_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: true,
|
||||
let columns = (0..column_count)
|
||||
.map(|index| ColumnDefinition {
|
||||
name: self.column_names[index].clone(),
|
||||
data_type: self.column_types[index].clone(),
|
||||
indexed: is_yes(&self.column_indexed[index]),
|
||||
quantity_ledger: is_yes(&self.column_quantity_ledger[index]),
|
||||
money_mode: if self.column_rounding[index].trim() == MoneyMode::Rounded.label() {
|
||||
MoneyMode::Rounded
|
||||
} else {
|
||||
MoneyMode::Exact
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
links.extend(
|
||||
comma_separated(&self.optional_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let has_money = columns.iter().any(|column| {
|
||||
column.field_type.eq_ignore_ascii_case("money")
|
||||
|| column.field_type.eq_ignore_ascii_case("accounting")
|
||||
});
|
||||
let base_currency = self.base_currency.trim().to_ascii_uppercase();
|
||||
if has_money && base_currency.is_empty() {
|
||||
return Err("A base currency is required when a MONEY column is used.".to_string());
|
||||
}
|
||||
let link_count = self.link_tables.len().min(self.link_modes.len());
|
||||
let links = (0..link_count)
|
||||
.map(|index| LinkDefinition {
|
||||
linked_table_name: self.link_tables[index].clone(),
|
||||
mode: LinkMode::from_label(&self.link_modes[index]),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(PostTableDefinitionRequest {
|
||||
accounting_currency: String::new(),
|
||||
table_name,
|
||||
links,
|
||||
// Drop display columns whose column is gone, so a stale post cannot
|
||||
// send a display column that no longer exists.
|
||||
let row_display_columns = self
|
||||
.row_display_columns
|
||||
.iter()
|
||||
.filter(|display| columns.iter().any(|column| &&column.name == display))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
TableDraft {
|
||||
profile_name: if creating_new_profile {
|
||||
String::new()
|
||||
} else {
|
||||
self.profile_name.trim().to_string()
|
||||
},
|
||||
profile_name_input: self.profile_name_input.clone(),
|
||||
creating_new_profile,
|
||||
accounting_currency: self.accounting_currency.clone(),
|
||||
table_name: self.table_name.clone(),
|
||||
base_currency: self.base_currency.clone(),
|
||||
column_name_input: self.column_name_input.clone(),
|
||||
column_type_input: self.column_type_input.clone(),
|
||||
temporal_type_input: self.temporal_type_input.clone(),
|
||||
gtin_type_input: self.gtin_type_input.clone(),
|
||||
column_indexing_input: self.column_indexing_input.clone(),
|
||||
column_quantity_ledger_input: self.column_quantity_ledger_input.clone(),
|
||||
column_rounding_input: self.column_rounding_input.clone(),
|
||||
columns,
|
||||
indexes,
|
||||
profile_name,
|
||||
base_currency: if has_money { base_currency } else { String::new() },
|
||||
row_display_columns: comma_separated(&self.row_display_columns),
|
||||
})
|
||||
links,
|
||||
row_display_columns,
|
||||
// Filled in by the loader from the live profile tree, never by the
|
||||
// client: it is what duplicate table names are checked against.
|
||||
existing_profile_tables: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
/// What the page and the builder fragment render.
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub draft: TableDraft,
|
||||
/// The outcome of the last builder action, if any.
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AddTablePageState {
|
||||
/// The value the profile `<select>` should show as chosen.
|
||||
pub(crate) fn selected_profile(&self) -> &str {
|
||||
if self.draft.creating_new_profile {
|
||||
NEW_PROFILE
|
||||
} else {
|
||||
&self.draft.profile_name
|
||||
}
|
||||
}
|
||||
|
||||
/// Row-display candidates: `id` first, then every column, matching the
|
||||
/// client's candidate list.
|
||||
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
|
||||
let mut candidates = vec![RowDisplayCandidate {
|
||||
index: 0,
|
||||
name: "id".to_string(),
|
||||
position: if self.draft.row_display_columns.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}];
|
||||
candidates.extend(self.draft.columns.iter().enumerate().map(|(index, column)| {
|
||||
RowDisplayCandidate {
|
||||
index: index + 1,
|
||||
name: column.name.clone(),
|
||||
position: self.draft.row_display_position(&column.name),
|
||||
}
|
||||
}));
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RowDisplayCandidate {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
/// `Some(0)` marks `id` as chosen; `Some(n)` is a column's 1-based place.
|
||||
pub position: Option<usize>,
|
||||
}
|
||||
|
||||
impl RowDisplayCandidate {
|
||||
/// The selection mark, matching the client: a tick for `id`, otherwise the
|
||||
/// column's place in the display order.
|
||||
pub(crate) fn mark(&self) -> String {
|
||||
match self.position {
|
||||
Some(0) => "[x]".to_string(),
|
||||
Some(position) => format!("[{position}]"),
|
||||
None => "[ ]".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_columns_indexes_links_and_money_options() {
|
||||
let request = CreateTableForm {
|
||||
profile_name: "accounting".into(),
|
||||
fn posted_form() -> BuilderForm {
|
||||
BuilderForm {
|
||||
profile_name: "billing".into(),
|
||||
table_name: "invoice".into(),
|
||||
columns: "number: text:indexed\namount: money:half-up,quantity-ledger".into(),
|
||||
required_links: "customer".into(),
|
||||
base_currency: "eur".into(),
|
||||
row_display_columns: "number, amount".into(),
|
||||
column_names: vec!["number".into(), "total".into()],
|
||||
column_types: vec!["text".into(), "money".into()],
|
||||
column_indexed: vec!["yes".into(), "no".into()],
|
||||
column_quantity_ledger: vec!["no".into(), "no".into()],
|
||||
column_rounding: vec!["exact".into(), "half-up".into()],
|
||||
link_tables: vec!["customer".into(), "project".into()],
|
||||
link_modes: vec!["required".into(), "none".into()],
|
||||
row_display_columns: vec!["number".into()],
|
||||
..Default::default()
|
||||
}
|
||||
.into_request()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(request.indexes, vec!["number"]);
|
||||
assert_eq!(request.links[0].linked_table_name, "customer");
|
||||
assert!(request.links[0].required);
|
||||
assert_eq!(request.base_currency, "EUR");
|
||||
assert_eq!(request.row_display_columns, vec!["number", "amount"]);
|
||||
assert!(request.columns[1].quantity_ledger);
|
||||
#[test]
|
||||
fn round_trips_columns_links_and_display_columns() {
|
||||
let draft = posted_form().to_draft();
|
||||
|
||||
assert_eq!(draft.columns.len(), 2);
|
||||
assert!(draft.columns[0].indexed);
|
||||
assert_eq!(draft.columns[1].money_mode, MoneyMode::Rounded);
|
||||
assert_eq!(draft.links[0].mode, LinkMode::Required);
|
||||
assert_eq!(draft.links[1].mode, LinkMode::None);
|
||||
assert_eq!(draft.row_display_columns, vec!["number"]);
|
||||
assert!(!draft.creating_new_profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_profile_option_switches_to_the_typed_name() {
|
||||
let mut form = posted_form();
|
||||
form.profile_name = NEW_PROFILE.to_string();
|
||||
form.profile_name_input = " bookkeeping ".to_string();
|
||||
|
||||
let draft = form.to_draft();
|
||||
|
||||
assert!(draft.creating_new_profile);
|
||||
assert_eq!(draft.profile_name, "");
|
||||
assert_eq!(draft.effective_profile_name(), "bookkeeping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_column_vectors_never_mis_pair() {
|
||||
let mut form = posted_form();
|
||||
form.column_types.pop();
|
||||
|
||||
let draft = form.to_draft();
|
||||
|
||||
assert_eq!(draft.columns.len(), 1);
|
||||
assert_eq!(draft.columns[0].name, "number");
|
||||
assert_eq!(draft.columns[0].data_type, "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_columns_for_removed_columns_are_dropped() {
|
||||
let mut form = posted_form();
|
||||
form.row_display_columns = vec!["number".into(), "gone".into()];
|
||||
|
||||
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_draft_never_trusts_the_posted_table_list() {
|
||||
// `existing_profile_tables` is what duplicate-name checks read, so it
|
||||
// must come from the server, not the form.
|
||||
assert!(posted_form().to_draft().existing_profile_tables.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user