659 lines
24 KiB
Rust
659 lines
24 KiB
Rust
//! 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<String>,
|
|
temporal_types: Vec<String>,
|
|
gtin_types: Vec<String>,
|
|
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<String>,
|
|
temporal_types: Vec<String>,
|
|
gtin_types: Vec<String>,
|
|
/// 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<String>,
|
|
temporal_types: Vec<String>,
|
|
gtin_types: Vec<String>,
|
|
/// 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 <code>invoice</code> 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 <code>currencies</code> 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#"<option value="money""#));
|
|
assert!(!html.contains(r#"<option value="accounting""#));
|
|
}
|
|
|
|
#[test]
|
|
fn the_append_panel_offers_link_targets_and_an_alias() {
|
|
let mut state = page();
|
|
state.columns.type_input = "link".to_string();
|
|
// The link targets are their own list: a profile's table may point at
|
|
// a shared one, which is not among the profile's own tables.
|
|
state.link_targets.push(table("customer", "dynamic"));
|
|
state.link_targets.push(TableSummary {
|
|
global: true,
|
|
..table("currencies", "dynamic")
|
|
});
|
|
state.link_targets.push(table("audit_log", "system"));
|
|
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(html.contains(r#">FKlink</option>"#));
|
|
assert!(html.contains("Link alias"));
|
|
assert!(html.contains(r#"name="link_table_input""#));
|
|
assert!(html.contains(r#"<option value="customer""#));
|
|
assert!(html.contains(r#"<optgroup label="Global">"#));
|
|
assert!(html.contains(r#"<optgroup label="User-created">"#));
|
|
assert!(html.contains(r#"<optgroup label="System-created">"#));
|
|
assert!(!html.contains(r#"<option value="invoice""#));
|
|
// The chart of accounts is reached through ACCOUNTING, never linked to.
|
|
assert!(!html.contains(r#"<option value="accounts""#));
|
|
}
|
|
|
|
/// The append screen holds a column to the same rules the builder does,
|
|
/// because both are the rules the server applies.
|
|
#[test]
|
|
fn the_append_panel_leaves_a_links_index_to_the_server() {
|
|
let mut state = page();
|
|
assert!(render_column_panel(&state).contains(r#"name="column_indexing_input""#));
|
|
|
|
state.columns.type_input = "link".to_string();
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(!html.contains(r#"name="column_indexing_input""#), "{html}");
|
|
assert!(html.contains("Indexed automatically"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_append_panel_offers_required_and_reports_it() {
|
|
let mut state = page();
|
|
assert!(render_column_panel(&state).contains(r#"name="column_required_input""#));
|
|
|
|
state.columns.added.push(crate::schema::ColumnDefinition {
|
|
name: "issued_on".to_string(),
|
|
data_type: "date".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
required: true,
|
|
money_mode: crate::schema::MoneyMode::Exact,
|
|
currency: String::new(),
|
|
});
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(html.contains(r#"name="column_required" value="yes""#));
|
|
assert!(html.contains(r#"<span class="tag">required</span>"#));
|
|
}
|
|
|
|
/// A staged link is reported as indexed rather than offered a toggle that
|
|
/// would send a request the server refuses.
|
|
#[test]
|
|
fn a_staged_link_is_shown_as_indexed_by_the_server() {
|
|
let mut state = page();
|
|
state.columns.added.push(crate::schema::ColumnDefinition {
|
|
name: "billing_customer".to_string(),
|
|
data_type: "link(customer)".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
required: false,
|
|
money_mode: crate::schema::MoneyMode::Exact,
|
|
currency: String::new(),
|
|
});
|
|
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(html.contains("indexed automatically"), "{html}");
|
|
assert!(!html.contains(r#""action": "toggle-index", "index": "0""#));
|
|
}
|
|
|
|
/// A shared table keeps no quantity ledger, which belongs to one profile.
|
|
#[test]
|
|
fn the_append_panel_offers_no_quantity_ledger_on_a_shared_table() {
|
|
let mut state = page();
|
|
assert!(render_column_panel(&state).contains(r#"name="column_quantity_ledger_input""#));
|
|
|
|
state.columns.global = true;
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(!html.contains(r#"name="column_quantity_ledger_input""#), "{html}");
|
|
assert!(html.contains("keeps no books of its own"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
|
|
let mut state = page();
|
|
assert!(!render_add_columns_fragment(&state).contains(r#"role="dialog""#));
|
|
|
|
state.error = Some("That column already exists.".to_string());
|
|
let html = render_add_columns_fragment(&state);
|
|
assert!(html.contains(r#"role="dialog""#));
|
|
// Once in the inline alert, once in the dialog — and not a third time
|
|
// from the column panel the fragment embeds.
|
|
assert_eq!(html.matches("That column already exists.").count(), 2);
|
|
}
|
|
|
|
/// Staging a column swaps the panel alone, so a refused column has to be
|
|
/// reported inside it or it is reported nowhere.
|
|
#[test]
|
|
fn a_refused_column_is_reported_in_the_panel_that_swapped() {
|
|
let mut state = page();
|
|
state.error = Some("Column name uses a reserved name.".to_string());
|
|
|
|
let html = render_column_panel(&state);
|
|
|
|
assert!(!html.contains("Template error"), "{html}");
|
|
assert!(html.contains(r#"role="dialog""#));
|
|
assert_eq!(html.matches("Column name uses a reserved name.").count(), 2);
|
|
}
|
|
|
|
/// A successful write reports the DDL the backend ran, which is the only
|
|
/// place the user gets to see it.
|
|
#[test]
|
|
fn a_successful_write_shows_its_ddl() {
|
|
let mut state = page();
|
|
state.status = Some("1 column added to `invoice`.".to_string());
|
|
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
|
|
|
|
let html = render_add_columns_fragment(&state);
|
|
|
|
assert!(html.contains("ALTER TABLE"));
|
|
assert!(html.contains("1 column added"));
|
|
}
|
|
|
|
#[test]
|
|
fn global_tables_have_their_own_scope() {
|
|
let mut state = page();
|
|
state.selection.profile = "__global".to_string();
|
|
|
|
let html = render_presentation_page(&state);
|
|
|
|
assert!(!html.contains("Template error"), "{html}");
|
|
assert!(html.contains("Global — all profiles"));
|
|
// Copying and the invoice template are per profile, so the global
|
|
// scope offers neither.
|
|
assert!(!html.contains("/admin/profiles/copy"));
|
|
assert!(!html.contains("/admin/tables/from-template"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_load_failure_answers_with_the_dialog() {
|
|
let html = render_load_error(crate::i18n::Locale::default(), "The backend is unreachable.");
|
|
|
|
assert!(!html.contains("Template error"), "{html}");
|
|
assert!(html.contains(r#"role="dialog""#));
|
|
assert!(html.contains("The backend is unreachable."));
|
|
}
|
|
}
|