Files
komp_ac/web/src/pages/add_table/state.rs
2026-08-12 17:46:58 +02:00

298 lines
10 KiB
Rust

//! 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 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`].
//!
//! The column half of that form is the shared one — the field names here are
//! the same ones [`crate::schema::ColumnForm`] declares, and both are rebuilt
//! by [`crate::schema::columns_from_rows`], so the two screens that describe
//! columns cannot drift apart.
use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows};
use super::draft::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 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 global: bool,
#[serde(default)]
pub table_name: String,
// The pending column being described in the input panel.
#[serde(default)]
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 link_table_input: String,
#[serde(default)]
pub decimal_precision_input: String,
#[serde(default)]
pub decimal_scale_input: String,
#[serde(default)]
pub column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
#[serde(default)]
pub column_rounding_input: String,
#[serde(default)]
pub column_currency_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>,
#[serde(default)]
pub column_currencies: Vec<String>,
// Tables the profile offers as link targets, in order.
#[serde(default)]
pub relation_tables: Vec<String>,
#[serde(default)]
pub row_display_columns: Vec<String>,
}
impl BuilderForm {
pub(crate) fn creating_new_profile(&self) -> bool {
self.profile_name == NEW_PROFILE
}
/// Rebuilds the draft this form was rendered from.
pub(crate) fn to_draft(&self) -> TableDraft {
let creating_new_profile = self.creating_new_profile();
let columns = ColumnDraft {
name_input: self.column_name_input.clone(),
type_input: self.column_type_input.clone(),
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
link_table_input: self.link_table_input.clone(),
decimal_precision_input: self.decimal_precision_input.clone(),
decimal_scale_input: self.decimal_scale_input.clone(),
indexing_input: self.column_indexing_input.clone(),
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
rounding_input: self.column_rounding_input.clone(),
currency_input: self.column_currency_input.clone(),
added: columns_from_rows(
&self.column_names,
&self.column_types,
&self.column_indexed,
&self.column_quantity_ledger,
&self.column_rounding,
&self.column_currencies,
),
// Filled in by the loader from `ListColumnTypes`, never by the
// form: it is the vocabulary the draft is validated against.
catalog: ColumnCatalog::default(),
// The table is being created here, so the creation-only types are
// on the table.
creating_table: true,
};
// 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.added.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(),
global: self.global,
table_name: self.table_name.clone(),
columns,
relation_tables: self.relation_tables.clone(),
relation_table_options: Vec::new(),
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(),
}
}
}
/// 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
.added
.iter()
.enumerate()
// A compound column expands into schema-managed companions, so
// there is no column of that name for a row to be shown by.
.filter(|(index, _)| self.draft.columns.is_indexable(*index))
.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::*;
use crate::schema::MoneyMode;
fn posted_form() -> BuilderForm {
BuilderForm {
profile_name: "billing".into(),
table_name: "invoice".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()],
column_currencies: vec![String::new(), "EUR".into()],
relation_tables: vec!["customer".into(), "project".into()],
row_display_columns: vec!["number".into()],
..Default::default()
}
}
#[test]
fn round_trips_columns_relation_tables_and_display_columns() {
let draft = posted_form().to_draft();
assert_eq!(draft.columns.added.len(), 2);
assert!(draft.columns.added[0].indexed);
assert_eq!(draft.columns.added[1].money_mode, MoneyMode::Rounded);
assert_eq!(draft.relation_tables, vec!["customer", "project"]);
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.added.len(), 1);
assert_eq!(draft.columns.added[0].name, "number");
assert_eq!(draft.columns.added[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());
}
}