Files
komp_ac/web/src/pages/add_table/ui.rs

346 lines
13 KiB
Rust

use askama::Template;
use crate::{
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<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
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> {
page: &'a AddTablePageState,
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
}
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 {
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(message: &str) -> String {
render(&Alert::error("Could not create the table", 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,
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(),
profiles: vec!["billing".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.
#[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,
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#"<input type="hidden" name="generated_alias_sources" value="{generated}">"#
)),
"`{generated}` should be aliasable"
);
}
assert!(html.contains(r#"name="generated_alias_names""#));
}
#[test]
fn the_page_offers_the_new_profile_option_and_the_currency_list() {
let html = render_page(&page());
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"value="__new__""#));
assert!(html.contains(r#"<datalist id="currency-codes">"#));
assert!(html.contains(r#"<option value="EUR">"#));
}
#[test]
fn the_scope_picker_explains_profile_and_shared_tables() {
let html = render_builder(&page());
assert!(html.contains("Where should this table be available?"));
assert!(html.contains(r#"name="global" value="false""#));
assert!(html.contains(r#"name="global" value="true""#));
assert!(html.contains("Which profile?"));
assert!(html.contains("Only the selected profile can use this table."));
assert!(html.contains("Every profile can use this shared table."));
}
/// Every type the server accepts has to be reachable from the picker, or
/// the web UI silently offers less than the backend does.
#[test]
fn the_type_picker_offers_the_parameterised_and_interval_types() {
let html = render_builder(&page());
for column_type in ["decimal", "duration", "period", "accounting"] {
assert!(
html.contains(&format!(r#"<option value="{column_type}""#)),
"the type picker is missing {column_type}"
);
}
}
#[test]
fn conditional_fields_follow_the_pending_column_type() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="temporal_type_input""#));
state.draft.columns.type_input = "temporal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="temporal_type_input""#));
assert!(!html.contains(r#"name="gtin_type_input""#));
state.draft.columns.type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Decimal reveals its precision and scale.
state.draft.columns.type_input = "decimal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="decimal_precision_input""#));
assert!(html.contains(r#"name="decimal_scale_input""#));
// Money reveals its currency and rounding inputs.
state.draft.columns.type_input = "money".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="column_rounding_input""#));
assert!(html.contains(r#"list="currency-codes""#));
state.draft.columns.type_input = "link".to_string();
let html = render_builder(&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">"#));
}
/// Choosing a compound type says what it will add, instead of leaving the
/// choice to be discovered after the table exists.
#[test]
fn choosing_accounting_shows_the_columns_it_will_generate() {
let mut state = page();
assert!(!render_builder(&state).contains("compound-note"));
state.draft.columns.type_input = "accounting".to_string();
let html = render_builder(&state);
assert!(html.contains("compound-note"));
for generated in ["name", "tax_point_date", "debit", "credit", "account_id"] {
assert!(
html.contains(&format!("<code>{generated}</code>")),
"the note is missing {generated}"
);
}
// A definition row is named after its own type, so there is nothing to
// type in and nothing to index.
assert!(!html.contains(r#"name="column_name_input""#));
assert!(!html.contains(r#"name="column_indexing_input""#));
// It does declare the currency its DEBIT and CREDIT are kept in.
assert!(html.contains(r#"name="column_currency_input""#));
}
/// The column list can be reordered, and the ends say so by refusing.
#[test]
fn columns_carry_move_buttons_that_stop_at_the_ends() {
let mut state = page();
state.draft.columns.added.push(ColumnDefinition {
name: "total".to_string(),
data_type: "money".to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: "EUR".to_string(),
});
let html = render_builder(&state);
assert!(html.contains(r#""action": "move-column-down", "index": "0""#));
assert!(html.contains(r#""action": "move-column-up", "index": "1""#));
// The first column cannot move up, nor the last one down.
assert!(!html.contains(r#""action": "move-column-up", "index": "0""#));
assert!(!html.contains(r#""action": "move-column-down", "index": "1""#));
assert_eq!(html.matches("disabled").count(), 2);
}
/// And an added definition row is listed with the columns it stands for.
#[test]
fn the_column_list_shows_generated_columns_under_their_definition_row() {
let mut state = page();
state.draft.columns.added.push(ColumnDefinition {
name: "accounting".to_string(),
data_type: "accounting".to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: "EUR".to_string(),
});
let html = render_builder(&state);
assert!(html.contains("generated-row"));
assert!(html.contains("generated by accounting"));
assert!(html.contains("<code>tax_point_date</code>"));
// A generated column is not the user's to remove: the definition row
// is the only one of the two with a Remove button.
assert_eq!(html.matches("remove-column").count(), 2);
}
#[test]
fn the_new_profile_fields_appear_only_for_a_new_profile() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="profile_name_input" value"#));
state.draft.creating_new_profile = true;
let html = render_builder(&state);
assert!(html.contains(r#"name="profile_name_input""#));
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
}
/// The dialog only exists when there is a failure, and it carries the same
/// message as the inline alert behind it.
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"role="dialog""#));
state.error = Some("That column already exists.".to_string());
let html = render_builder(&state);
assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("x-data=\"{ dangerModalIsOpen: true }\""));
assert_eq!(html.matches("That column already exists.").count(), 2);
}
/// The dialog is Tailwind + Alpine, so the page has to load them; the
/// `head` block lives two levels up, in `ui/base.html`.
#[test]
fn the_page_loads_what_the_dialog_needs() {
let html = render_page(&page());
assert!(html.contains("@tailwindcss/browser@4"));
assert!(html.contains("@alpinejs/focus@3"));
assert!(html.contains("htmx:beforeSwap"));
}
/// A load failure has no draft to render, so the dialog is the response.
#[test]
fn a_load_failure_answers_with_the_dialog() {
let html = render_submission_error("The backend is unreachable.");
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("The backend is unreachable."));
}
}