web interface for table_definition improved

This commit is contained in:
Priec
2026-08-04 17:20:58 +02:00
parent c2002c7da3
commit 803207b1af
20 changed files with 2989 additions and 612 deletions

View File

@@ -0,0 +1,306 @@
use askama::Template;
use crate::{
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
ui::{Alert, Nav, render},
};
use super::state::TableDefinitionPageState;
/// GET /admin/table-definition — the shell around the workspace.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/table_definition.html")]
struct TableDefinitionPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
currency_codes: &'static [&'static str],
/// False, as on the workspace fragment: the page embeds both, and the
/// outcome is reported once, at the top.
standalone_column_panel: bool,
}
/// The `#table-definition-workspace` swap, which is the same markup the page
/// embeds, so one template serves both.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/workspace.html")]
struct WorkspaceFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
/// False: the workspace 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> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
/// True: this is the whole response, so a refused column has nowhere else
/// to be reported.
standalone_column_panel: bool,
}
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
render(&TableDefinitionPage {
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: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
currency_codes: CURRENCY_CODES,
standalone_column_panel: false,
})
}
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
render(&WorkspaceFragment {
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: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
standalone_column_panel: false,
})
}
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
render(&ColumnPanelFragment {
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: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
standalone_column_panel: true,
})
}
/// Used when the workspace 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(message: &str) -> String {
render(&Alert::error("Table definition unavailable", message))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
TableSummary,
},
schema::ColumnDraft,
};
fn table(name: &str, kind: &str) -> TableSummary {
TableSummary {
name: name.to_string(),
table_kind: kind.to_string(),
depends_on: Vec::new(),
row_display_columns: vec!["number".to_string()],
}
}
fn page() -> TableDefinitionPageState {
TableDefinitionPageState {
nav: Nav::default(),
profiles: vec!["billing".to_string(), "payroll".to_string()],
selection: Selection {
profile: "billing".to_string(),
table: "invoice".to_string(),
},
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
detail: Some(TableDetailView {
id: 7,
name: "invoice".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: vec!["number".to_string()],
scripts: Vec::new(),
columns: vec![DetailColumn {
name: "number".to_string(),
field_type: "text".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
generated: false,
read_only: false,
generated_from: String::new(),
}],
}),
history: Vec::new(),
columns: ColumnDraft::for_append(),
rename: RenameForm::default(),
copy: CopyForm::default(),
invoice: InvoiceTemplateForm::default(),
status: None,
error: None,
sql: None,
generated: Vec::new(),
}
}
/// The point of the page: every write the service offers is reachable
/// from the one screen, for the one selection.
#[test]
fn the_workspace_offers_every_table_definition_write() {
let html = render_workspace(&page());
assert!(!html.contains("Template error"), "{html}");
for route in [
"/admin/table-definition/columns",
"/admin/table-definition/rename",
"/admin/table-definition/delete",
"/admin/table-definition/copy",
"/admin/table-definition/invoice-template",
] {
assert!(html.contains(route), "missing the {route} panel");
}
// And creating a table, which is the one write that has its own page.
assert!(html.contains("/admin/tables/new?profile=billing"));
}
/// 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,
money_mode: crate::schema::MoneyMode::Rounded,
currency: "EUR".to_string(),
});
let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}");
// Escaped, because it is an attribute: `&amp;` is what a browser reads
// back as the `&` separating the two parameters.
// 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""#));
}
/// A system table is the backend's own; every write below is refused for
/// it, so the workspace shows the definition and stops there.
#[test]
fn a_system_table_is_readable_but_not_writable() {
let mut state = page();
state.selection.table = "accounts".to_string();
state.detail = state.detail.map(|mut detail| {
detail.name = "accounts".to_string();
detail.table_kind = "system".to_string();
detail
});
let html = render_workspace(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("backend-managed"));
assert!(!html.contains("/admin/table-definition/delete"));
assert!(!html.contains("/admin/table-definition/rename"));
}
/// With only a profile chosen, the profile-wide panels are there and the
/// table-wide ones are not.
#[test]
fn the_panels_follow_how_much_has_been_selected() {
let mut state = page();
state.selection.table = String::new();
state.detail = None;
let html = render_workspace(&state);
assert!(html.contains("/admin/table-definition/copy"));
assert!(html.contains("/admin/table-definition/invoice-template"));
assert!(!html.contains("/admin/table-definition/delete"));
// With nothing chosen at all, only the profile picker is.
state.selection.profile = String::new();
state.tables.clear();
let html = render_workspace(&state);
assert!(!html.contains("/admin/table-definition/copy"));
assert!(html.contains("Choose a profile"));
}
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_workspace(&state).contains(r#"role="dialog""#));
state.error = Some("That column already exists.".to_string());
let html = render_workspace(&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 workspace 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_workspace(&state);
assert!(html.contains("ALTER TABLE"));
assert!(html.contains("1 column added"));
}
#[test]
fn a_load_failure_answers_with_the_dialog() {
let html = render_load_error("The backend is unreachable.");
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("The backend is unreachable."));
}
}