435 lines
16 KiB
Rust
435 lines
16 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::{ACCOUNTING_FIELD_TYPE, 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
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
let mut tags = Vec::new();
|
|
if column.quantity_ledger {
|
|
tags.push("quantity ledger".to_string());
|
|
}
|
|
if !column.option_label().is_empty() {
|
|
tags.push(column.option_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),
|
|
indexed: column.indexed,
|
|
tags,
|
|
});
|
|
|
|
for generated in columns.generated_columns_of(index) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
// The account foreign key is a system column rather than a
|
|
// generated user column, so the catalog does not report it; the
|
|
// same explanation as in `TableDraft::preview_rows`.
|
|
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(),
|
|
"written as account".to_string(),
|
|
],
|
|
});
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
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"]);
|
|
}
|
|
|
|
/// 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(),
|
|
profiles: Vec::new(),
|
|
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,
|
|
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_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()));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|