use askama::Template; use crate::{ {i18n::Locale, tr}, schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; use super::state::AddTablePageState; /// GET /admin/tables/new — the page shell around the builder. #[derive(Template)] #[template(path = "pages/add_table/add_table.html")] struct AddTablePage<'a> { nav: Nav, page: &'a AddTablePageState, column_types: Vec, temporal_types: Vec, gtin_types: Vec, currency_codes: &'static [&'static str], } /// POST /admin/tables/builder — the `#builder` swap, which is the same markup /// the page embeds, so one template serves both. #[derive(Template)] #[template(path = "pages/add_table/builder.html")] struct BuilderFragment<'a> { nav: Nav, page: &'a AddTablePageState, column_types: Vec, temporal_types: Vec, gtin_types: Vec, } pub(crate) fn render_page(page: &AddTablePageState) -> String { render(&AddTablePage { nav: page.nav.clone(), page, // Every picker is the backend's own vocabulary, asked through the // draft so it can only offer what the draft would accept. column_types: page.draft.columns.offered_types(), temporal_types: page.draft.columns.temporal_types(), gtin_types: page.draft.columns.gtin_types(), currency_codes: CURRENCY_CODES, }) } pub(crate) fn render_builder(page: &AddTablePageState) -> String { render(&BuilderFragment { nav: page.nav.clone(), page, column_types: page.draft.columns.offered_types(), temporal_types: page.draft.columns.temporal_types(), gtin_types: page.draft.columns.gtin_types(), }) } /// Used when the page itself cannot be loaded (auth or backend failure). /// There is no draft left to render, so this replaces the builder — the dialog /// is what tells the user why the form just emptied. pub(crate) fn render_submission_error(locale: Locale, message: &str) -> String { render(&Alert::error( locale, &tr!(locale, "builder-err-title"), message, )) } #[cfg(test)] mod tests { use super::*; use crate::{ pages::add_table::draft::{RelationTableOption, TableDraft}, schema::{ColumnDefinition, MoneyMode}, }; fn page() -> AddTablePageState { let mut draft = TableDraft::new(); draft.columns.catalog = crate::schema::tests::catalog(); draft.profile_name = "billing".to_string(); draft.table_name = "invoice".to_string(); draft.columns.added.push(ColumnDefinition { name: "number".to_string(), data_type: "text".to_string(), indexed: true, quantity_ledger: false, required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); draft.set_available_relation_table_options(vec![ RelationTableOption { name: "customer".to_string(), global: false, system: false, }, RelationTableOption { name: "currencies".to_string(), global: true, system: false, }, RelationTableOption { name: "audit_log".to_string(), global: false, system: true, }, ]); draft.toggle_row_display_candidate(1); AddTablePageState { nav: Nav::default(), shared_profile: "__global".to_string(), profiles: vec!["billing".to_string()], rate_sources: vec!["ecb".to_string()], draft, status: None, error: None, } } /// `render` turns a template failure into an error string rather than /// panicking, so the markup has to be asserted on. #[test] fn the_builder_carries_the_whole_draft_and_the_preview() { let html = render_builder(&page()); assert!(!html.contains("Template error"), "{html}"); // Every part of the draft travels with the next request. assert!(html.contains(r#"name="column_names" value="number""#)); assert!(html.contains(r#"name="column_indexed" value="yes""#)); assert!(html.contains(r#"name="relation_tables" value="customer""#)); assert!(html.contains(r#"name="row_display_columns" value="number""#)); // The preview shows the schema as it will exist. assert!(html.contains("BIGSERIAL")); assert!(html.contains("TIMESTAMPTZ")); } /// The columns an ACCOUNTING row generates get a name field of their own, /// which is what makes them aliasable at creation time — in a section of /// their own, not in the column list, which is there to be read. #[test] fn generated_accounting_columns_get_an_alias_field() { let mut page = page(); page.draft.columns.added.push(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 html = render_builder(&page); assert!(!html.contains("Template error"), "{html}"); for generated in ["name", "tax_point_date", "debit", "credit", "account"] { assert!( html.contains(&format!( r#""# )), "`{generated}` should be aliasable" ); } assert!(html.contains(r#"name="generated_alias_names""#)); // The column list itself carries no input at all. let list = html .split("

Columns") .nth(1) .and_then(|rest| rest.split("").next()) .expect("the column list should be rendered"); assert!(!list.contains("Rename generated columns")); } /// A rename is reported where the column is listed, so the list still says /// what the table will look like without the section being opened. #[test] fn a_renamed_generated_column_says_so_in_the_column_list() { let mut page = page(); page.draft.columns.added.push(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(), }); page.draft.generated_aliases = vec![crate::pages::add_table::draft::GeneratedAlias { source: "debit".to_string(), alias: "md".to_string(), }]; let html = render_builder(&page); assert!(html.contains(r#"renamed to md"#), "{html}"); assert!(html.contains(r#"name="generated_alias_names" value="md""#)); // The names only reach the column list when something posts them, and // the section stays open so the applied names can be read back. assert!(html.contains(r#""action": "apply-generated-names""#)); assert!(html.contains(r#"
"#)); } /// With no name asked for, the section is the closed default it was. #[test] fn the_rename_section_starts_closed() { let mut page = page(); page.draft.columns.added.push(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 html = render_builder(&page); assert!(html.contains(r#"
"#), "{html}"); } #[test] fn the_page_offers_backend_vocabularies_for_profile_setup() { let html = render_page(&page()); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains(r#"value="__new__""#)); assert!(html.contains(r#""#)); assert!(html.contains(r#"