add table is better now

This commit is contained in:
Priec
2026-08-03 14:44:12 +02:00
parent c8afe99d79
commit 023f8c9dcb
11 changed files with 1799 additions and 212 deletions

View File

@@ -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());
}
}