//! One template struct per page, and one per swap target inside it. //! //! The pages share a state type and an action switcher, so what differs //! between them here is only which panel they render. The column vocabulary //! is threaded through the add-column templates, because askama resolves //! those fields on the struct rather than on `page`. use askama::Template; use crate::{ {i18n::Locale, tr}, schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; use super::state::TableDefinitionPageState; /// GET /admin/tables/columns/add #[derive(Template)] #[template(path = "pages/admin/table_definition/add_columns.html")] struct AddColumnsPage<'a> { nav: Nav, page: &'a TableDefinitionPageState, column_types: Vec, temporal_types: Vec, gtin_types: Vec, currency_codes: &'static [&'static str], /// False, as on the fragment: the page reports the outcome once, at the /// top of the panel containing this fragment. standalone_column_panel: bool, } /// The `#table-panel` swap on the add-columns page. #[derive(Template)] #[template(path = "pages/admin/table_definition/add_columns_panel.html")] struct AddColumnsFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, column_types: Vec, temporal_types: Vec, gtin_types: Vec, /// False: the fragment shows the outcome of the last action itself, at the /// top, so the panel it embeds must not repeat it. standalone_column_panel: bool, } /// The `#column-panel` swap, for staging a column without writing anything. #[derive(Template)] #[template(path = "pages/admin/table_definition/column_panel.html")] struct ColumnPanelFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, column_types: Vec, temporal_types: Vec, gtin_types: Vec, /// True: this is the whole response, so a refused column has nowhere else /// to be reported. standalone_column_panel: bool, } /// GET /admin/tables/presentation #[derive(Template)] #[template(path = "pages/admin/table_definition/presentation.html")] struct PresentationPage<'a> { nav: Nav, page: &'a TableDefinitionPageState, } /// The `#table-panel` swap on the column-presentation page. #[derive(Template)] #[template(path = "pages/admin/table_definition/presentation_panel.html")] struct PresentationFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, } /// GET /admin/tables/delete #[derive(Template)] #[template(path = "pages/admin/table_definition/delete.html")] struct DeletePage<'a> { nav: Nav, page: &'a TableDefinitionPageState, } #[derive(Template)] #[template(path = "pages/admin/table_definition/delete_panel.html")] struct DeleteFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, } /// GET /admin/profiles/copy #[derive(Template)] #[template(path = "pages/admin/table_definition/copy.html")] struct CopyPage<'a> { nav: Nav, page: &'a TableDefinitionPageState, } #[derive(Template)] #[template(path = "pages/admin/table_definition/copy_panel.html")] struct CopyFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, } /// GET /admin/tables/from-template #[derive(Template)] #[template(path = "pages/admin/table_definition/from_template.html")] struct TemplatePage<'a> { nav: Nav, page: &'a TableDefinitionPageState, } #[derive(Template)] #[template(path = "pages/admin/table_definition/from_template_panel.html")] struct TemplateFragment<'a> { nav: Nav, page: &'a TableDefinitionPageState, } /// GET /admin/profiles/history #[derive(Template)] #[template(path = "pages/admin/table_definition/history.html")] struct HistoryPage<'a> { nav: Nav, page: &'a TableDefinitionPageState, } pub(crate) fn render_add_columns_page(page: &TableDefinitionPageState) -> String { render(&AddColumnsPage { nav: page.nav.clone(), page, // Asking the draft, so the picker can only ever offer what the draft // would accept — the accounting rule is stated once. column_types: page.columns.offered_types(), temporal_types: page.columns.temporal_types(), gtin_types: page.columns.gtin_types(), currency_codes: CURRENCY_CODES, standalone_column_panel: false, }) } pub(crate) fn render_add_columns_fragment(page: &TableDefinitionPageState) -> String { render(&AddColumnsFragment { nav: page.nav.clone(), page, column_types: page.columns.offered_types(), temporal_types: page.columns.temporal_types(), gtin_types: page.columns.gtin_types(), standalone_column_panel: false, }) } pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String { render(&ColumnPanelFragment { nav: page.nav.clone(), page, column_types: page.columns.offered_types(), temporal_types: page.columns.temporal_types(), gtin_types: page.columns.gtin_types(), standalone_column_panel: true, }) } pub(crate) fn render_presentation_page(page: &TableDefinitionPageState) -> String { render(&PresentationPage { nav: page.nav.clone(), page, }) } pub(crate) fn render_presentation_fragment(page: &TableDefinitionPageState) -> String { render(&PresentationFragment { nav: page.nav.clone(), page, }) } pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String { render(&DeletePage { nav: page.nav.clone(), page, }) } pub(crate) fn render_delete_fragment(page: &TableDefinitionPageState) -> String { render(&DeleteFragment { nav: page.nav.clone(), page, }) } pub(crate) fn render_copy_page(page: &TableDefinitionPageState) -> String { render(&CopyPage { nav: page.nav.clone(), page, }) } pub(crate) fn render_copy_fragment(page: &TableDefinitionPageState) -> String { render(&CopyFragment { nav: page.nav.clone(), page, }) } pub(crate) fn render_template_page(page: &TableDefinitionPageState) -> String { render(&TemplatePage { nav: page.nav.clone(), page, }) } pub(crate) fn render_template_fragment(page: &TableDefinitionPageState) -> String { render(&TemplateFragment { nav: page.nav.clone(), page, }) } pub(crate) fn render_history_page(page: &TableDefinitionPageState) -> String { render(&HistoryPage { nav: page.nav.clone(), page, }) } /// Used when a page itself cannot be loaded. There is nothing left to render, /// so the dialog is what tells the user why. pub(crate) fn render_load_error(locale: Locale, message: &str) -> String { render(&Alert::error( locale, &tr!(locale, "td-unavailable-title"), message, )) } #[cfg(test)] mod tests { use super::*; use crate::{ pages::admin::table_definition::state::{ CopyForm, DetailColumn, InvoiceTemplateForm, Selection, TableDetailView, TableSummary, }, schema::ColumnDraft, }; fn table(name: &str, kind: &str) -> TableSummary { TableSummary { name: name.to_string(), table_kind: kind.to_string(), global: false, depends_on: Vec::new(), } } fn page() -> TableDefinitionPageState { TableDefinitionPageState { nav: Nav::default(), selection: Selection { profile: "billing".to_string(), table: "invoice".to_string(), }, tables: vec![table("invoice", "dynamic"), table("accounts", "system")], link_targets: vec![table("invoice", "dynamic"), table("accounts", "system")], detail: Some(TableDetailView { id: 7, row_version: 1, row_display_columns: vec!["number".to_string()], scripts: Vec::new(), columns: vec![DetailColumn { column_id: 1, name: "number".to_string(), field_type: "text".to_string(), sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, generated: false, read_only: false, generated_from: String::new(), renameable: true, }], }), history: Vec::new(), columns: ColumnDraft::for_append(crate::schema::tests::catalog()), copy: CopyForm::default(), invoice: InvoiceTemplateForm::default(), status: None, error: None, sql: None, generated: Vec::new(), active: "presentation", } } /// The point of the split: each write is its own page, and the switcher /// they share is how you get from one to the next. #[test] fn every_page_links_to_the_others() { let mut state = page(); for (active, html) in [ ("add-columns", render_add_columns_page(&state)), ("presentation", render_presentation_page(&state)), ("delete", render_delete_page(&state)), ] { state.active = active; assert!(!html.contains("Template error"), "{html}"); } let html = render_presentation_page(&state); for route in [ "/admin/tables/columns/add?profile=billing", "/admin/tables/presentation?profile=billing", "/admin/tables/delete?profile=billing", "/admin/profiles/copy?profile=billing", "/admin/tables/from-template?profile=billing", "/admin/profiles/history?profile=billing", "/permissions/grants", ] { // Escaped, because it is an attribute: `&` is what a browser // reads back as the `&` separating the two parameters. let route = route.replace('&', "&"); assert!(html.contains(&route), "missing the {route} link\n{html}"); } // And back to where the table was picked. assert!(html.contains(r#"href="/admin?profile=billing&table=invoice""#)); } /// Deleting is its own page, so the form is the only thing on it — no /// scrolling past five other panels to reach it. #[test] fn the_delete_page_is_only_the_delete_form() { let html = render_delete_page(&page()); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("/admin/tables/delete")); assert!(html.contains("Type invoice to confirm")); // The other writes are links in the switcher, not forms on the page. assert!(!html.contains(r#"hx-post="/admin/tables/presentation""#)); assert!(!html.contains("/admin/profiles/copy?profile=billing\" method")); } /// The other half of `a_global_table_is_offered_the_same_actions`: what the /// admin panel links to for a global table is a page that deletes it, /// rather than one that says no table was chosen. #[test] fn the_delete_page_accepts_a_global_table() { let mut state = page(); state.selection = Selection { profile: crate::pages::GLOBAL_SCOPE.to_string(), table: "currencies".to_string(), }; state.tables = vec![TableSummary { name: "currencies".to_string(), table_kind: "dynamic".to_string(), global: true, depends_on: Vec::new(), }]; state.active = "delete"; let html = render_delete_page(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("Type currencies to confirm"), "{html}"); assert!(!html.contains("No table chosen")); // The sentinel is never shown as if it were a profile's name. assert!(html.contains("Global — all profiles")); } /// And when the table really is gone, the page says which one rather than /// telling someone who just picked one to go and pick one. The loader is /// what clears the selection and sets the reason. #[test] fn a_dropped_selection_is_explained() { let mut state = page(); state.selection.table = String::new(); state.detail = None; state.error = Some("`invoice` is not a table of billing.".to_string()); state.active = "delete"; let html = render_delete_page(&state); assert!(html.contains("`invoice` is not a table of billing."), "{html}"); assert!(html.contains("No table chosen")); } /// A system table is the backend's own; every write is refused for it, so /// none of the write pages offer their form. #[test] fn a_system_table_gets_no_write_form() { let mut state = page(); state.selection.table = "accounts".to_string(); let html = render_delete_page(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("backend's own")); assert!(!html.contains(r#"name="confirm_table_name""#)); let html = render_add_columns_page(&state); assert!(!html.contains(r#"id="column-form""#)); let html = render_presentation_page(&state); assert!(!html.contains(r#"name="aliases""#)); } #[test] fn column_presentation_posts_stable_ids_aliases_and_order_controls() { let html = render_presentation_page(&page()); assert!(html.contains(r#"hx-post="/admin/tables/presentation""#), "{html}"); assert!(html.contains(r#"name="column_ids" value="1""#), "{html}"); assert!(html.contains(r#"name="expected_row_version" value="1""#), "{html}"); assert!(html.contains(r#"name="aliases" value="number""#), "{html}"); assert!(html.contains(r#"name="action" value="save""#), "{html}"); } #[test] fn adding_columns_and_presentation_are_separate_pages() { let add_html = render_add_columns_page(&page()); assert!(add_html.contains(r#"id="column-form""#), "{add_html}"); assert!(!add_html.contains(r#"name="aliases""#), "{add_html}"); let presentation_html = render_presentation_page(&page()); assert!(presentation_html.contains(r#"name="aliases""#), "{presentation_html}"); assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}"); } /// The profile-wide pages need only a profile, and say so by still /// rendering with no table selected. #[test] fn the_profile_pages_do_not_need_a_table() { let mut state = page(); state.selection.table = String::new(); state.detail = None; state.active = "copy"; let html = render_copy_page(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("/admin/profiles/copy")); // Table-scoped links are absent, because there is no table. assert!(!html.contains("/admin/tables/delete")); state.active = "template"; let html = render_template_page(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("/admin/tables/from-template")); state.active = "history"; let html = render_history_page(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("has been renamed")); } /// The append panel posts the selection in its URL, so the column fields /// themselves stay exactly the ones the Add-table builder posts. #[test] fn the_column_panel_carries_the_selection_and_the_staged_columns() { let mut state = page(); state.columns.name_input = "issued_on".to_string(); state.columns.type_input = "temporal".to_string(); state.columns.added.push(crate::schema::ColumnDefinition { name: "total".to_string(), data_type: "money".to_string(), indexed: true, quantity_ledger: false, required: false, money_mode: crate::schema::MoneyMode::Rounded, currency: "EUR".to_string(), }); let html = render_column_panel(&state); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("/admin/tables/columns/add/builder")); // Escaped, because it is an attribute: `&` is what a browser reads // back as the `&` separating the two parameters. assert!(html.contains("?profile=billing&table=invoice")); assert!(html.contains(r#"name="column_names" value="total""#)); assert!(html.contains(r#"name="column_indexed" value="yes""#)); assert!(html.contains(r#"name="column_currencies" value="EUR""#)); // The pending type is temporal, so its subtype picker is showing. assert!(html.contains(r#"name="temporal_type_input""#)); } /// ACCOUNTING brings schema-managed companions with it, so the server only /// accepts it while the table is created. The panel must not offer it. #[test] fn the_append_panel_never_offers_an_accounting_column() { let html = render_column_panel(&page()); assert!(html.contains(r#""#)); assert!(html.contains("Link alias")); assert!(html.contains(r#"name="link_table_input""#)); assert!(html.contains(r#""#)); assert!(html.contains(r#""#)); assert!(!html.contains(r#"