Files
komp_ac/web/src/pages/add_table/state.rs
2026-09-04 23:46:22 +02:00

629 lines
24 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::{
ACCOUNT_API_COLUMN, ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE, GeneratedAlias,
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 rate_source_id: String,
#[serde(default)]
pub foreign_currencies: 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 column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
#[serde(default)]
pub column_required_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_required: 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>,
// One pair per generated column offered an alias, in list order: the
// generated column's own name, and what the user typed for it.
#[serde(default)]
pub generated_alias_sources: Vec<String>,
#[serde(default)]
pub generated_alias_names: 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 (added, ragged) = columns_from_rows(
&self.column_names,
&self.column_types,
&self.column_indexed,
&self.column_quantity_ledger,
&self.column_required,
&self.column_rounding,
&self.column_currencies,
);
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(),
indexing_input: self.column_indexing_input.clone(),
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
required_input: self.column_required_input.clone(),
rounding_input: self.column_rounding_input.clone(),
currency_input: self.column_currency_input.clone(),
added,
ragged,
// 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,
// What the table will be, so the panel can hold a column to the
// rules of the table it is being described for: a shared table
// takes no column that posts to one profile's books, and no link
// may point at the table being created.
global: self.global,
table_name: self.table_name.clone(),
};
// 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(),
rate_source_id: self.rate_source_id.clone(),
foreign_currencies: self.foreign_currencies.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(),
// Which of these still name a generated column is the catalog's
// answer, and the catalog is not filled in yet, so a pair is kept
// here and matched up when the aliases are read.
generated_aliases: self
.generated_alias_sources
.iter()
.zip(self.generated_alias_names.iter())
.map(|(source, alias)| GeneratedAlias {
source: source.trim().to_string(),
alias: alias.clone(),
})
.collect(),
}
}
}
/// What the page and the builder fragment render.
pub(crate) struct AddTablePageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
/// Provider IDs returned by ExchangeRateService.ListRateSources.
pub rate_sources: Vec<String>,
/// The profile a global table lands in, as the profile tree reports it.
/// The builder posts it as the created table's scope.
pub shared_profile: 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
}
}
/// The "Columns" list: every declared column, each followed by the columns
/// it expands into.
///
/// The expansion is flattened here rather than nested in the template so
/// the markup stays one loop over one list, and so what a definition row
/// brings is decided in one place.
pub(crate) fn column_rows(&self) -> Vec<ColumnRow> {
let columns = &self.draft.columns;
let last_index = columns.added.len().saturating_sub(1);
let mut rows = Vec::new();
for (index, column) in columns.added.iter().enumerate() {
// Whether it is indexed has a column of its own, so it is not
// repeated here.
let mut tags = Vec::new();
if column.required {
tags.push("required".to_string());
}
if column.quantity_ledger {
tags.push("quantity ledger".to_string());
}
if !column.currency.is_empty() {
tags.push(format!(
"{}, {}",
column.currency,
column.money_mode.label()
));
}
rows.push(ColumnRow {
index: Some(index),
first: index == 0,
last: index == last_index,
name: column.name.clone(),
data_type: column.data_type.clone(),
indexable: columns.is_indexable(index),
// A link is indexed whether or not anyone asked: the server
// builds an index for every foreign key.
indexed: column.is_indexed(),
tags,
alias_source: None,
alias: String::new(),
});
// Every generated column with persisted provenance may be named by
// the request. Transfer connectors are still resolved by fixed
// backend names and remain the exception.
let aliasable = column.data_type != ACCOUNTING_TRANSFER_FIELD_TYPE;
let generated_columns = columns.generated_columns_of(index);
// Whether the backend already reports `account` as one of them, in
// which case the foreign-key row below is a second view of a column
// that is named once, not a column of its own to name again.
let account_reported = generated_columns
.iter()
.any(|generated| generated.name == ACCOUNT_API_COLUMN);
for generated in generated_columns {
let mut tags = vec![format!("generated by {}", column.data_type)];
if generated.inherits_currency {
tags.push(format!(
"{}, {}",
column.currency,
column.money_mode.label()
));
}
rows.push(ColumnRow {
index: None,
first: false,
last: false,
name: generated.name.clone(),
data_type: generated.data_type.clone(),
indexable: false,
indexed: false,
tags,
alias_source: aliasable.then(|| generated.name.clone()),
alias: self.draft.alias_for(&generated.name).to_string(),
});
}
// The physical column the account foreign key is stored in. It is
// a system column, so it is listed whether or not the catalog
// reports `account` among the generated ones — but it is named
// there when it does, never twice.
if column.data_type == ACCOUNTING_FIELD_TYPE {
rows.push(ColumnRow {
index: None,
first: false,
last: false,
name: "account_id".to_string(),
data_type: "BIGINT".to_string(),
indexable: false,
indexed: true,
tags: vec![
"system column".to_string(),
format!(
"written as {}",
self.draft.generated_display_name(ACCOUNT_API_COLUMN)
),
],
alias_source: (!account_reported).then(|| ACCOUNT_API_COLUMN.to_string()),
alias: self.draft.alias_for(ACCOUNT_API_COLUMN).to_string(),
});
}
}
rows
}
/// The generated columns that may be renamed, in the order they are
/// listed.
///
/// Renaming lives in a section of its own rather than in the column list:
/// the list is where the table is read, and a text box in every other row
/// of it turns reading into scanning past inputs.
/// One field per generated column and no more: a source named twice would
/// be two fields over one column, and the draft reads the first of them,
/// so whatever was typed in the second would be dropped without a word.
pub(crate) fn alias_rows(&self) -> Vec<AliasRow> {
let mut rows: Vec<AliasRow> = Vec::new();
for row in self.column_rows() {
let Some(source) = row.alias_source else {
continue;
};
if rows.iter().any(|existing| existing.source == source) {
continue;
}
rows.push(AliasRow {
source,
alias: row.alias,
data_type: row.data_type,
});
}
rows
}
/// Row-display candidates: `id` first, then every column the table will
/// really hold.
///
/// That includes the columns a definition row generates — they are columns
/// like any other once the table exists, and the server accepts them here.
/// The definition row itself is not among them: it leaves no column of its
/// own name behind for a row to be shown by.
///
/// The index is the candidate's place in this list, so the button that
/// toggles one names the same column the label does however the list is
/// filtered.
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
},
}];
for name in self.draft.row_display_column_names() {
candidates.push(RowDisplayCandidate {
index: candidates.len(),
position: self.draft.row_display_position(&name),
name,
});
}
candidates
}
}
/// One line of the "Columns" list: either a column the user declared, or one
/// the server will generate from the definition row above it.
pub(crate) struct ColumnRow {
/// Where the column sits in the draft, for the buttons that act on it.
/// `None` for a generated column, which is not the user's to act on: it
/// moves and is removed with the definition row it came from.
pub index: Option<usize>,
/// Whether it can move any further up, and any further down.
pub first: bool,
pub last: bool,
pub name: String,
pub data_type: String,
pub indexable: bool,
pub indexed: bool,
pub tags: Vec<String>,
/// The generated column an alias would rename, when this row is one that
/// may be aliased. `None` for a declared column — it is named where it is
/// described — and for the columns the backend refuses to rename.
pub alias_source: Option<String>,
/// The alias typed for it so far.
pub alias: String,
}
/// One line of the "Rename generated columns" section: a generated column the
/// request may name, and the name asked for it so far.
pub(crate) struct AliasRow {
/// The generated column's own name, which is what the rename asks for.
pub source: String,
/// The alias typed for it, empty when the backend's name is being kept.
pub alias: String,
pub data_type: String,
}
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_required: vec!["yes".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"]);
}
/// The column list shows a definition row with the columns it expands
/// into. Only the definition row is the user's to act on: the generated
/// ones carry no index, so they get no buttons.
#[test]
fn the_column_list_shows_what_a_definition_row_expands_into() {
let mut page = AddTablePageState {
nav: crate::ui::Nav::default(),
shared_profile: "global".to_string(),
profiles: Vec::new(),
rate_sources: vec!["ecb".to_string()],
draft: posted_form().to_draft(),
status: None,
error: None,
};
page.draft.columns.catalog = crate::schema::tests::catalog();
page.draft
.columns
.added
.push(crate::schema::ColumnDefinition {
name: "accounting".to_string(),
data_type: "accounting".to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: "EUR".to_string(),
});
let rows = page.column_rows();
assert_eq!(
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
[
"number",
"total",
"accounting",
"name",
"tax_point_date",
"debit",
"credit",
"account",
"account_id",
]
);
let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap();
// Only the three declared columns can be moved, indexed or removed.
assert_eq!(
rows.iter().filter(|row| row.index.is_some()).count(),
3,
"only declared columns carry an index"
);
assert!(row("number").first);
assert!(row("accounting").last);
assert!(
!row("accounting").indexable,
"a definition row is no column"
);
assert!(row("debit").tags.contains(&"EUR, exact".to_string()));
assert!(
row("name")
.tags
.contains(&"generated by accounting".to_string())
);
}
/// The generated columns of an ACCOUNTING row are the user's to alias, and
/// the alias travels back with the rest of the draft.
#[test]
fn the_generated_columns_of_an_accounting_row_offer_an_alias() {
let mut form = posted_form();
form.column_names.push("accounting".into());
form.column_types.push("accounting".into());
form.column_indexed.push("no".into());
form.column_quantity_ledger.push("no".into());
form.column_required.push("no".into());
form.column_rounding.push("exact".into());
form.column_currencies.push("EUR".into());
form.generated_alias_sources = vec!["debit".into(), "account".into()];
form.generated_alias_names = vec!["Md 🙂".into(), "ucet".into()];
let mut draft = form.to_draft();
draft.columns.catalog = crate::schema::tests::catalog();
assert_eq!(draft.alias_for("debit"), "Md 🙂");
assert_eq!(
draft.aliased_generated_columns(),
vec![
("debit".to_string(), "Md 🙂".to_string()),
("account".to_string(), "ucet".to_string()),
]
);
let page = AddTablePageState {
nav: crate::ui::Nav::default(),
shared_profile: "global".to_string(),
profiles: Vec::new(),
rate_sources: vec!["ecb".to_string()],
draft,
status: None,
error: None,
};
let rows = page.column_rows();
let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap();
assert_eq!(row("Md 🙂").alias_source.as_deref(), Some("debit"));
assert_eq!(row("Md 🙂").alias, "Md 🙂");
// `account` is reported by the catalog, so it is named there and the
// physical column it lands in is not offered a second field.
assert_eq!(row("account").alias_source.as_deref(), Some("account"));
assert_eq!(row("account").alias, "ucet");
assert!(row("account_id").alias_source.is_none());
assert!(
row("account_id")
.tags
.contains(&"written as ucet".to_string())
);
assert!(
row("accounting").alias_source.is_none(),
"a declared column is named where it is described"
);
}
#[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());
}
}