web interface for table_definition improved
This commit is contained in:
306
web/src/pages/admin/table_definition/ui.rs
Normal file
306
web/src/pages/admin/table_definition/ui.rs
Normal 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: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
// 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""#));
|
||||
}
|
||||
|
||||
/// 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."));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user