//! 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, #[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, #[serde(default)] pub column_types: Vec, #[serde(default)] pub column_indexed: Vec, #[serde(default)] pub column_quantity_ledger: Vec, #[serde(default)] pub column_required: Vec, #[serde(default)] pub column_rounding: Vec, #[serde(default)] pub column_currencies: Vec, // Tables the profile offers as link targets, in order. #[serde(default)] pub relation_tables: Vec, #[serde(default)] pub row_display_columns: Vec, // 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, #[serde(default)] pub generated_alias_names: Vec, } 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, /// Provider IDs returned by ExchangeRateService.ListRateSources. pub rate_sources: Vec, /// 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, pub error: Option, } impl AddTablePageState { /// The value the profile `