Files
komp_ac/web/src/pages/admin/table_definition/ui.rs
2026-08-15 14:58:36 +02:00

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: `&#38;` is what a browser
// reads back as the `&` separating the two parameters.
let route = route.replace('&', "&#38;");
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&#38;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: `&#38;` is what a browser reads
// back as the `&` separating the two parameters.
assert!(html.contains("?profile=billing&#38;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."));
}
}