From 023f8c9dcb4eeb9a2fc1ebaa6dbe6b5274997051 Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 3 Aug 2026 14:44:12 +0200 Subject: [PATCH] add table is better now --- Cargo.lock | 40 + web/Cargo.toml | 4 + web/src/pages/add_table/draft.rs | 939 +++++++++++++++++++ web/src/pages/add_table/loader.rs | 54 +- web/src/pages/add_table/logic.rs | 142 ++- web/src/pages/add_table/mod.rs | 2 + web/src/pages/add_table/state.rs | 385 +++++--- web/src/pages/add_table/ui.rs | 136 ++- web/static/app.css | 30 + web/templates/pages/add_table/add_table.html | 42 +- web/templates/pages/add_table/builder.html | 237 +++++ 11 files changed, 1799 insertions(+), 212 deletions(-) create mode 100644 web/src/pages/add_table/draft.rs create mode 100644 web/templates/pages/add_table/builder.html diff --git a/Cargo.lock b/Cargo.lock index 7c6796e7..f6504e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,6 +610,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -6546,6 +6571,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -8727,8 +8765,10 @@ version = "0.1.0" dependencies = [ "askama", "axum", + "axum-extra", "prost", "prost-types", + "rusty-money", "serde", "serde_json", "tokio", diff --git a/web/Cargo.toml b/web/Cargo.toml index 7b0a820c..5a446bd4 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -6,6 +6,10 @@ edition = "2024" [dependencies] askama = "0.15.1" axum = "0.8" +# `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and +# the table builder posts one set of fields per already-added column. +axum-extra = { version = "0.10", features = ["form"] } +rusty-money = "0.5.0" prost = "0.14.4" prost-types = "0.14.4" serde = { version = "1", features = ["derive"] } diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs new file mode 100644 index 00000000..dd260613 --- /dev/null +++ b/web/src/pages/add_table/draft.rs @@ -0,0 +1,939 @@ +//! The Add-table draft and its rules. +//! +//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics +//! with the terminal-specific parts removed (`ratatui` cursors, the canvas +//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below — +//! the type catalogue, canonicalisation, field visibility and validation — +//! matches the client so the two frontends accept and reject exactly the same +//! table definitions. +//! +//! Keeping it dependency-light is deliberate: this module is the candidate for +//! extraction into a crate shared by `client`, `web` and `server`. Everything +//! above [`TableDraft::into_request`] is already proto-free; that one method is +//! the seam where a shared crate would hand back a plain draft for each +//! frontend to map to its own generated request type. + +use crate::definitions::table_definition::{ + ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest, + TableLink as ProtoTableLink, +}; + +/// Column types offered in the type picker. `temporal` and `gtin` are pickers +/// of their own: neither is a storable type, each resolves to a subtype below. +pub(crate) const COLUMN_TYPES: &[&str] = &[ + "text", + "boolean", + "money", + "accounting", + "int", + "temporal", + "phone", + "iban", + "email_address", + "credit_card", + "gtin", + "bigint", +]; + +pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"]; +pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"]; + +/// Every type the server accepts, i.e. what a canonicalised column may be. +const CANONICAL_TYPES: &[&str] = &[ + "text", + "boolean", + "date", + "time", + "instant", + "raw_datetime", + "phone", + "iban", + "email_address", + "credit_card", + "gtin_8", + "gtin_12", + "gtin_13", + "gtin_14", + "money", + "accounting", + "int", + "bigint", +]; + +/// Types a quantity-ledger column may use. +fn quantity_ledger_type_allowed(field_type: &str) -> bool { + matches!(field_type, "int" | "bigint" | "money") + || (field_type.starts_with("decimal(") && field_type.ends_with(')')) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum MoneyMode { + #[default] + Exact, + Rounded, +} + +impl MoneyMode { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Rounded => "half-up", + } + } + + fn from_input(value: &str) -> Self { + if value.trim().eq_ignore_ascii_case("half-up") { + Self::Rounded + } else { + Self::Exact + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum LinkMode { + #[default] + None, + Optional, + Required, +} + +impl LinkMode { + pub(crate) fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::Optional => "optional", + Self::Required => "required", + } + } + + /// Cycles none → optional → required → none, as `Select` does in the TUI. + pub(crate) fn next(self) -> Self { + match self { + Self::None => Self::Optional, + Self::Optional => Self::Required, + Self::Required => Self::None, + } + } + + pub(crate) fn from_label(value: &str) -> Self { + match value.trim() { + "optional" => Self::Optional, + "required" => Self::Required, + _ => Self::None, + } + } + + pub(crate) fn is_active(self) -> bool { + !matches!(self, Self::None) + } + + pub(crate) fn is_required(self) -> bool { + matches!(self, Self::Required) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ColumnDefinition { + pub name: String, + pub data_type: String, + pub indexed: bool, + pub quantity_ledger: bool, + pub money_mode: MoneyMode, +} + +impl ColumnDefinition { + /// The `option` cell of the preview, mirroring the client's preview table. + pub(crate) fn option_label(&self) -> String { + let is_money = self.data_type.eq_ignore_ascii_case("money"); + match (self.indexed, is_money) { + (true, true) => format!("indexed, {}", self.money_mode.label()), + (true, false) => "indexed".to_string(), + (false, true) => self.money_mode.label().to_string(), + (false, false) => String::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LinkDefinition { + pub linked_table_name: String, + pub mode: LinkMode, +} + +/// One row of the "Table definition preview" — the schema as it will exist. +pub(crate) struct PreviewRow { + pub mark: String, + pub column: String, + pub data_type: String, + pub option: String, + pub source: String, +} + +/// The whole Add-table page state, minus presentation. +#[derive(Clone, Debug, Default)] +pub(crate) struct TableDraft { + /// Profile this table belongs to when an existing one was picked. + pub profile_name: String, + /// Profile name typed in when creating a new profile. + pub profile_name_input: String, + pub creating_new_profile: bool, + pub accounting_currency: String, + + pub table_name: String, + pub base_currency: String, + + // The column-input panel: one pending column being described. + pub column_name_input: String, + pub column_type_input: String, + pub temporal_type_input: String, + pub gtin_type_input: String, + pub column_indexing_input: String, + pub column_quantity_ledger_input: String, + pub column_rounding_input: String, + + pub columns: Vec, + pub links: Vec, + /// Columns identifying a row to users, in the order they are shown. + /// Empty means rows are identified by their id alone. + pub row_display_columns: Vec, + + /// Tables already defined in the target profile — a new table may not + /// reuse one of these names. + pub existing_profile_tables: Vec, +} + +impl TableDraft { + /// A draft for a brand-new page load, matching the client's defaults. + pub(crate) fn new() -> Self { + Self { + accounting_currency: "EUR".to_string(), + base_currency: "EUR".to_string(), + column_indexing_input: "no".to_string(), + column_quantity_ledger_input: "no".to_string(), + column_rounding_input: "none".to_string(), + ..Self::default() + } + } + + // ---- pending-column input ------------------------------------------- + + pub(crate) fn is_money_column_input(&self) -> bool { + let value = self.column_type_input.trim(); + value.eq_ignore_ascii_case("money") || value.eq_ignore_ascii_case("accounting") + } + + pub(crate) fn is_temporal_column_input(&self) -> bool { + self.column_type_input.trim().eq_ignore_ascii_case("temporal") + } + + pub(crate) fn is_gtin_column_input(&self) -> bool { + self.column_type_input.trim().eq_ignore_ascii_case("gtin") + } + + /// The storable type the pending inputs describe, resolving the `temporal` + /// and `gtin` pickers to their subtype. `None` while the choice is still + /// incomplete. + fn canonical_column_type_input(&self) -> Option { + let column_type = self.column_type_input.trim().to_ascii_lowercase(); + match column_type.as_str() { + "temporal" => { + let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase(); + TEMPORAL_TYPES + .contains(&temporal_type.as_str()) + .then_some(temporal_type) + } + "gtin" => { + let gtin_type = self.gtin_type_input.trim(); + GTIN_TYPES + .contains(>in_type) + .then(|| format!("gtin_{gtin_type}")) + } + "" => None, + _ => Some(column_type), + } + } + + // ---- field visibility (the same rules the TUI canvas applies) -------- + + pub(crate) fn show_profile_name_input(&self) -> bool { + self.creating_new_profile + } + + pub(crate) fn show_accounting_currency(&self) -> bool { + self.creating_new_profile + } + + pub(crate) fn show_temporal_type(&self) -> bool { + self.is_temporal_column_input() + } + + pub(crate) fn show_gtin_type(&self) -> bool { + self.is_gtin_column_input() + } + + pub(crate) fn show_rounding(&self) -> bool { + self.is_money_column_input() + } + + pub(crate) fn show_base_currency(&self) -> bool { + self.is_money_column_input() || self.money_column_count() > 0 + } + + // ---- mutations ------------------------------------------------------- + + /// Appends the pending column, then clears the input panel. + pub(crate) fn add_column_from_inputs(&mut self) -> Result { + let Some(column_type) = self.canonical_column_type_input() else { + return Err("Both a column name and a column type are required.".to_string()); + }; + + // An accounting column is always named `accounting`. + let column_name = if column_type.eq_ignore_ascii_case("accounting") { + "accounting".to_string() + } else { + self.column_name_input.trim().to_string() + }; + + if column_name.is_empty() { + return Err("Both a column name and a column type are required.".to_string()); + } + if let Some(error) = validate_identifier(&column_name, "Column name", true) { + return Err(error); + } + if let Some(error) = validate_field_type(&column_type) { + return Err(error); + } + if self.columns.iter().any(|column| column.name == column_name) { + return Err(format!("A column named `{column_name}` already exists.")); + } + + let quantity_ledger = self + .column_quantity_ledger_input + .trim() + .eq_ignore_ascii_case("yes"); + if quantity_ledger && !quantity_ledger_type_allowed(&column_type) { + return Err( + "Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(), + ); + } + + let is_money = column_type.eq_ignore_ascii_case("money") + || column_type.eq_ignore_ascii_case("accounting"); + self.columns.push(ColumnDefinition { + name: column_name.clone(), + data_type: column_type, + indexed: self + .column_indexing_input + .trim() + .eq_ignore_ascii_case("yes"), + quantity_ledger, + money_mode: if is_money { + MoneyMode::from_input(&self.column_rounding_input) + } else { + MoneyMode::Exact + }, + }); + + self.clear_column_inputs(); + Ok(format!("Column `{column_name}` added.")) + } + + fn clear_column_inputs(&mut self) { + self.column_name_input.clear(); + self.column_type_input.clear(); + self.temporal_type_input.clear(); + self.gtin_type_input.clear(); + self.column_indexing_input = "no".to_string(); + self.column_quantity_ledger_input = "no".to_string(); + self.column_rounding_input = "none".to_string(); + } + + /// Removes one column, and drops it from the display columns with it. + pub(crate) fn remove_column(&mut self, index: usize) -> Result { + if index >= self.columns.len() { + return Err("That column no longer exists.".to_string()); + } + let removed = self.columns.remove(index); + self.row_display_columns + .retain(|display| display != &removed.name); + Ok(format!("Column `{}` removed.", removed.name)) + } + + pub(crate) fn toggle_column_indexed(&mut self, index: usize) { + if let Some(column) = self.columns.get_mut(index) { + column.indexed = !column.indexed; + } + } + + pub(crate) fn cycle_link_mode(&mut self, index: usize) { + if let Some(link) = self.links.get_mut(index) { + link.mode = link.mode.next(); + } + } + + /// Adds or removes one display-column candidate. + /// + /// Index 0 is `id`, which is not a display column of its own: choosing it + /// clears the list, since an empty list already means "identified by id". + /// Any other index toggles that column, appending so the order columns were + /// chosen in is the order they are shown in. + pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) { + if index == 0 { + self.row_display_columns.clear(); + return; + } + let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else { + return; + }; + match self + .row_display_columns + .iter() + .position(|display| *display == column) + { + Some(position) => { + self.row_display_columns.remove(position); + } + None => self.row_display_columns.push(column), + } + } + + /// Rebuilds the link list from the tables available in the target profile, + /// keeping whatever mode each surviving link already had. + pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec) { + let previous_modes = self + .links + .iter() + .map(|link| (link.linked_table_name.clone(), link.mode)) + .collect::>(); + + self.links = table_names + .into_iter() + .filter(|table_name| table_name != &self.table_name) + .map(|linked_table_name| LinkDefinition { + mode: previous_modes + .get(&linked_table_name) + .copied() + .unwrap_or(LinkMode::None), + linked_table_name, + }) + .collect(); + } + + // ---- derived state --------------------------------------------------- + + /// The profile name to validate and persist: the typed input while creating + /// a new profile, otherwise the profile that was picked. + pub(crate) fn effective_profile_name(&self) -> String { + if self.creating_new_profile { + self.profile_name_input.trim().to_string() + } else { + self.profile_name.clone() + } + } + + pub(crate) fn table_name_conflicts(&self) -> bool { + !self.table_name.is_empty() + && self + .existing_profile_tables + .iter() + .any(|name| name == &self.table_name) + } + + pub(crate) fn money_column_count(&self) -> usize { + self.columns + .iter() + .filter(|column| { + column.data_type.eq_ignore_ascii_case("money") + || column.data_type.eq_ignore_ascii_case("accounting") + }) + .count() + } + + pub(crate) fn selected_index_names(&self) -> Vec { + self.columns + .iter() + .filter(|column| column.indexed) + .map(|column| column.name.clone()) + .collect() + } + + /// Position of `column` among the display columns, counting from 1. + pub(crate) fn row_display_position(&self, column: &str) -> Option { + self.row_display_columns + .iter() + .position(|display| display == column) + .map(|index| index + 1) + } + + /// The schema as it will exist: system columns, relation columns, then the + /// user's own. Mirrors the client's preview pane. + pub(crate) fn preview_rows(&self) -> Vec { + let mut rows = vec![ + PreviewRow { + mark: if self.row_display_columns.is_empty() { + "[x]".to_string() + } else { + "[ ]".to_string() + }, + column: "id".to_string(), + data_type: "BIGSERIAL".to_string(), + option: "primary key".to_string(), + source: "system".to_string(), + }, + PreviewRow { + mark: String::new(), + column: "deleted".to_string(), + data_type: "BOOLEAN".to_string(), + option: "default false".to_string(), + source: "system".to_string(), + }, + ]; + + for link in self.links.iter().filter(|link| link.mode.is_active()) { + rows.push(PreviewRow { + mark: String::new(), + column: format!("{}_id", link.linked_table_name), + data_type: "BIGINT".to_string(), + option: if link.mode.is_required() { + "required".to_string() + } else { + "optional".to_string() + }, + source: "relation".to_string(), + }); + } + + for column in &self.columns { + rows.push(PreviewRow { + mark: self + .row_display_position(&column.name) + .map(|position| format!("[{position}]")) + .unwrap_or_else(|| "[ ]".to_string()), + column: column.name.clone(), + data_type: column.data_type.clone(), + option: column.option_label(), + source: "user".to_string(), + }); + } + + rows.push(PreviewRow { + mark: String::new(), + column: "created_at".to_string(), + data_type: "TIMESTAMPTZ".to_string(), + option: "current time".to_string(), + source: "system".to_string(), + }); + rows + } + + // ---- validation and submission --------------------------------------- + + /// Every check the client runs before it will save. + pub(crate) fn validate(&self) -> Result<(), String> { + let profile_name = self.effective_profile_name(); + if self.creating_new_profile && profile_name.is_empty() { + return Err("Enter a name for the new profile.".to_string()); + } + if let Some(error) = validate_identifier(&profile_name, "Profile name", false) { + return Err(error); + } + if let Some(error) = validate_accounting_currency(self) { + return Err(error); + } + if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) { + return Err(error); + } + if self.table_name_conflicts() { + return Err(format!( + "A table named `{}` already exists in profile `{}`.", + self.table_name, profile_name + )); + } + if self.columns.is_empty() { + return Err("Add at least one column before saving.".to_string()); + } + for column in &self.columns { + if let Some(error) = validate_identifier(&column.name, "Column name", true) { + return Err(error); + } + if let Some(error) = validate_field_type(&column.data_type) { + return Err(format!("Column `{}`: {error}", column.name)); + } + } + if let Some(error) = validate_base_currency(self) { + return Err(error); + } + Ok(()) + } + + pub(crate) fn into_request(mut self) -> Result { + self.table_name = self.table_name.trim().to_string(); + self.validate()?; + + Ok(PostTableDefinitionRequest { + table_name: self.table_name.clone(), + profile_name: self.effective_profile_name(), + columns: self + .columns + .iter() + .map(|column| ProtoColumnDefinition { + name: column.name.clone(), + field_type: column.data_type.clone(), + rounding: match column.money_mode { + MoneyMode::Rounded => MoneyRounding::HalfUp.into(), + MoneyMode::Exact => MoneyRounding::None.into(), + }, + quantity_ledger: column.quantity_ledger, + }) + .collect(), + indexes: self.selected_index_names(), + links: self + .links + .iter() + .filter(|link| link.mode.is_active()) + .map(|link| ProtoTableLink { + linked_table_name: link.linked_table_name.clone(), + required: link.mode.is_required(), + }) + .collect(), + base_currency: if self.money_column_count() == 0 { + String::new() + } else { + self.base_currency.trim().to_ascii_uppercase() + }, + accounting_currency: if self.creating_new_profile { + self.accounting_currency.trim().to_ascii_uppercase() + } else { + String::new() + }, + row_display_columns: self.row_display_columns.clone(), + }) + } +} + +pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option { + if draft.money_column_count() == 0 { + return None; + } + let currency = draft.base_currency.trim(); + if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) { + return Some("Base currency must be a three-letter ISO-4217 code".to_string()); + } + None +} + +pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { + if !draft.creating_new_profile { + return None; + } + let currency = draft.accounting_currency.to_ascii_uppercase(); + if rusty_money::iso::find(¤cy).is_none() { + return Some("Accounting currency must be a three-letter ISO-4217 code".to_string()); + } + None +} + +/// PostgreSQL identifier rules, plus the names this schema reserves. +pub(crate) fn validate_identifier( + value: &str, + label: &str, + reject_table_reserved: bool, +) -> Option { + if value.is_empty() { + return Some(format!("{label} cannot be empty.")); + } + if value != value.trim() { + return Some(format!("{label} cannot start or end with a space.")); + } + if value.starts_with('_') { + return Some(format!("{label} cannot start with an underscore.")); + } + if value.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Some(format!("{label} cannot start with a number.")); + } + if value.len() > 63 { + return Some(format!("{label} cannot be longer than 63 characters.")); + } + if value + .chars() + .any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_') + { + return Some(format!( + "{label} may only use lowercase letters, digits and underscores." + )); + } + if reject_table_reserved + && (value == "id" + || value == "deleted" + || value == "created_at" + || value == "row_revision" + || value.ends_with("_id")) + { + return Some(format!("{label} uses a reserved name.")); + } + if !reject_table_reserved + && (value == "public" || value == "information_schema" || value.starts_with("pg_")) + { + return Some("That profile name is reserved by PostgreSQL.".to_string()); + } + None +} + +pub(crate) fn validate_field_type(field_type: &str) -> Option { + let field_type = field_type.to_lowercase(); + if CANONICAL_TYPES.contains(&field_type.as_str()) { + return None; + } + Some(format!("`{field_type}` is not a valid field type.")) +} + +/// ISO-4217 codes offered as currency suggestions, matching the client's list. +pub(crate) const CURRENCY_CODES: &[&str] = &[ + "EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", + "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", + "BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK", + "DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", + "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", + "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", + "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", + "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", + "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", + "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL", + "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW", + "UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD", + "XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK", + "ZWL", +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn draft_with_column(name: &str, data_type: &str) -> TableDraft { + let mut draft = TableDraft::new(); + draft.profile_name = "billing".to_string(); + draft.table_name = "invoice".to_string(); + draft.columns.push(ColumnDefinition { + name: name.to_string(), + data_type: data_type.to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + }); + draft + } + + #[test] + fn temporal_and_gtin_pickers_resolve_to_canonical_types() { + let mut draft = TableDraft::new(); + draft.column_name_input = "occurred_at".to_string(); + draft.column_type_input = "temporal".to_string(); + + // Incomplete while no subtype is chosen. + assert!(draft.canonical_column_type_input().is_none()); + assert!(draft.show_temporal_type()); + + draft.temporal_type_input = "raw_datetime".to_string(); + draft.add_column_from_inputs().unwrap(); + assert_eq!(draft.columns[0].data_type, "raw_datetime"); + // Inputs are cleared for the next column. + assert!(draft.temporal_type_input.is_empty()); + + draft.column_name_input = "barcode".to_string(); + draft.column_type_input = "gtin".to_string(); + draft.gtin_type_input = "13".to_string(); + draft.add_column_from_inputs().unwrap(); + assert_eq!(draft.columns[1].data_type, "gtin_13"); + } + + #[test] + fn invalid_identifiers_and_types_are_refused_at_add_time() { + let mut draft = TableDraft::new(); + draft.column_type_input = "text".to_string(); + + draft.column_name_input = "Total".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + draft.column_name_input = "customer_id".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + draft.column_name_input = "created_at".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + + draft.column_name_input = "total".to_string(); + draft.column_type_input = "timestamptz".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + + draft.column_type_input = "text".to_string(); + assert!(draft.add_column_from_inputs().is_ok()); + // Duplicates are refused too. + draft.column_name_input = "total".to_string(); + draft.column_type_input = "text".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + } + + #[test] + fn quantity_ledger_requires_a_numeric_type() { + let mut draft = TableDraft::new(); + draft.column_name_input = "note".to_string(); + draft.column_type_input = "text".to_string(); + draft.column_quantity_ledger_input = "yes".to_string(); + assert!(draft.add_column_from_inputs().is_err()); + + draft.column_type_input = "int".to_string(); + assert!(draft.add_column_from_inputs().is_ok()); + assert!(draft.columns[0].quantity_ledger); + } + + #[test] + fn accounting_column_is_always_named_accounting() { + let mut draft = TableDraft::new(); + draft.column_name_input = "whatever".to_string(); + draft.column_type_input = "accounting".to_string(); + draft.add_column_from_inputs().unwrap(); + + assert_eq!(draft.columns[0].name, "accounting"); + } + + #[test] + fn money_columns_require_a_valid_base_currency() { + let mut draft = draft_with_column("total", "money"); + draft.base_currency = "EU".to_string(); + assert!(draft.validate().is_err()); + + draft.base_currency = "eur".to_string(); + assert!(draft.validate().is_ok()); + assert_eq!(draft.into_request().unwrap().base_currency, "EUR"); + } + + #[test] + fn new_profile_accounting_currency_must_exist_in_the_iso_registry() { + let mut draft = draft_with_column("total", "int"); + draft.creating_new_profile = true; + draft.profile_name_input = "billing".to_string(); + + draft.accounting_currency = "AAA".to_string(); + assert!(draft.validate().is_err()); + + draft.accounting_currency = "eur".to_string(); + assert!(draft.validate().is_ok()); + assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR"); + } + + #[test] + fn existing_profile_sends_no_accounting_currency() { + let draft = draft_with_column("total", "int"); + let request = draft.into_request().unwrap(); + + assert_eq!(request.accounting_currency, ""); + assert_eq!(request.profile_name, "billing"); + // No money column, so no base currency either. + assert_eq!(request.base_currency, ""); + } + + #[test] + fn duplicate_table_names_in_the_profile_are_refused() { + let mut draft = draft_with_column("total", "int"); + draft.existing_profile_tables = vec!["invoice".to_string()]; + + assert!(draft.validate().is_err()); + } + + #[test] + fn reserved_profile_names_are_refused() { + let mut draft = draft_with_column("total", "int"); + draft.profile_name = "pg_catalog".to_string(); + + assert!(draft.validate().is_err()); + } + + #[test] + fn links_keep_their_mode_when_the_table_list_is_reloaded() { + let mut draft = draft_with_column("total", "int"); + draft.set_available_relation_tables(vec!["customer".into(), "project".into()]); + draft.cycle_link_mode(0); // none -> optional + draft.cycle_link_mode(0); // optional -> required + + draft.set_available_relation_tables(vec![ + "customer".into(), + "project".into(), + "address".into(), + ]); + + assert_eq!(draft.links[0].mode, LinkMode::Required); + assert_eq!(draft.links[2].mode, LinkMode::None); + + let request = draft.into_request().unwrap(); + assert_eq!(request.links.len(), 1); + assert_eq!(request.links[0].linked_table_name, "customer"); + assert!(request.links[0].required); + } + + #[test] + fn a_table_never_links_to_itself() { + let mut draft = draft_with_column("total", "int"); + draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]); + + assert_eq!(draft.links.len(), 1); + assert_eq!(draft.links[0].linked_table_name, "customer"); + } + + #[test] + fn row_display_columns_toggle_in_the_order_they_were_chosen() { + let mut draft = draft_with_column("number", "text"); + draft.columns.push(ColumnDefinition { + name: "issued_on".to_string(), + data_type: "date".to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + }); + + draft.toggle_row_display_candidate(2); // issued_on + draft.toggle_row_display_candidate(1); // number + assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]); + assert_eq!(draft.row_display_position("number"), Some(2)); + + // Index 0 is `id`: choosing it clears the list. + draft.toggle_row_display_candidate(0); + assert!(draft.row_display_columns.is_empty()); + } + + #[test] + fn removing_a_column_drops_it_from_the_display_columns() { + let mut draft = draft_with_column("number", "text"); + draft.toggle_row_display_candidate(1); + assert_eq!(draft.row_display_columns, vec!["number"]); + + draft.remove_column(0).unwrap(); + assert!(draft.row_display_columns.is_empty()); + } + + #[test] + fn the_preview_shows_system_relation_and_user_columns() { + let mut draft = draft_with_column("number", "text"); + draft.set_available_relation_tables(vec!["customer".into()]); + draft.cycle_link_mode(0); + + let rows = draft.preview_rows(); + let columns = rows + .iter() + .map(|row| row.column.as_str()) + .collect::>(); + + assert_eq!( + columns, + vec!["id", "deleted", "customer_id", "number", "created_at"] + ); + assert_eq!(rows[2].option, "optional"); + // No display column chosen, so `id` identifies the row. + assert_eq!(rows[0].mark, "[x]"); + } + + #[test] + fn indexed_columns_become_the_index_list() { + let mut draft = draft_with_column("number", "text"); + draft.toggle_column_indexed(0); + + assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]); + } +} diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 6d5ab233..9463ebc7 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -1,22 +1,27 @@ use axum::http::HeaderMap; use crate::{ - AppState, - auth::GetAuthorizationRequest, - definitions::common::Empty, + AppState, auth::GetAuthorizationRequest, definitions::common::Empty, services::authenticated_request, }; -use super::state::{AddTablePageState, CreateTableForm}; +use super::{draft::TableDraft, state::AddTablePageState}; +/// Loads everything the builder needs around the draft: the profiles that can +/// be picked, and — for whichever profile the table will belong to — the tables +/// that are link targets and the names the new table may not reuse. +/// +/// The link targets and reserved names always come from the live profile tree, +/// never from the posted form, so they cannot be spoofed by a crafted request. pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, - form: CreateTableForm, + mut draft: TableDraft, + status: Option, error: Option, ) -> Result { - let authorization_request = - authenticated_request(headers, GetAuthorizationRequest {}).map_err(|_| LoadError::Unauthenticated)?; + let authorization_request = authenticated_request(headers, GetAuthorizationRequest {}) + .map_err(|_| LoadError::Unauthenticated)?; let mut auth = state.auth; let authorization = auth .get_authorization(authorization_request) @@ -38,10 +43,41 @@ pub(crate) async fn load_page( .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); + + let effective_profile = draft.effective_profile_name(); + match tree + .profiles + .iter() + .find(|profile| profile.name == effective_profile) + { + // An existing profile: its tables are the link targets, and their + // names are reserved against duplicate table creation. + Some(profile) => { + let table_names = profile + .tables + .iter() + .filter(|table| table.name != "accounts") + .map(|table| table.name.clone()) + .collect::>(); + draft.existing_profile_tables = table_names.clone(); + draft.set_available_relation_tables(table_names); + } + // A brand-new (or not-yet-named) profile has nothing to link to. + None => { + draft.existing_profile_tables.clear(); + draft.links.clear(); + } + } + Ok(AddTablePageState { nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), - profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(), - form, + profiles: tree + .profiles + .into_iter() + .map(|profile| profile.name) + .collect(), + draft, + status, error, }) } diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 9ed5b9c0..6bfc61d4 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -1,54 +1,85 @@ +//! The builder's request handlers. +//! +//! Every interaction posts the whole draft and swaps the whole builder back, +//! so the server stays the single owner of the draft's rules — the same ones +//! the TUI client applies in-process between keystrokes. + use axum::{ - Form, extract::State, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, }; +use axum_extra::extract::Form; use crate::{AppState, services::authenticated_request}; use super::{ + draft::TableDraft, loader::{LoadError, load_page}, - state::CreateTableForm, + state::{AddTablePageState, BuilderForm}, ui, }; -pub(crate) async fn new_table_page( - State(state): State, - headers: HeaderMap, -) -> Response { - render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await) +/// GET /admin/tables/new +pub(crate) async fn new_table_page(State(state): State, headers: HeaderMap) -> Response { + match load_page(state, &headers, TableDraft::new(), None, None).await { + Ok(page) => Html(ui::render_page(&page)).into_response(), + Err(error) => load_error_response(error), + } } +/// POST /admin/tables/builder — every button and every `change` in the builder. +pub(crate) async fn update_builder( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + + let mut page = match load_page(state, &headers, form.to_draft(), None, None).await { + Ok(page) => page, + Err(error) => return load_error_response(error), + }; + + apply_action(&mut page, &form); + Html(ui::render_builder(&page)).into_response() +} + +/// POST /admin/tables — create the table. pub(crate) async fn create_table( State(state): State, headers: HeaderMap, - Form(form): Form, + Form(form): Form, ) -> Response { - let submitted_form = form.clone(); - if let Err(error) = load_page(state.clone(), &headers, form.clone(), None).await { - return render_loaded(Err(error)); + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; } - if headers - .get("sec-fetch-site") - .is_some_and(|value| value == "cross-site") - { - return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); - } - let request = match form.into_request() { + + let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await { + Ok(page) => page, + Err(error) => return load_error_response(error), + }; + + // The draft is validated here with exactly the checks the client runs + // before it will save; the server re-validates authoritatively. + let request = match page.draft.clone().into_request() { Ok(request) => request, Err(message) => { - return render_loaded( - load_page(state, &headers, submitted_form, Some(message)).await, - ); + page.error = Some(message); + return (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))) + .into_response(); } }; + let profile_name = request.profile_name.clone(); - let mut definitions = state.definitions; let request = match authenticated_request(&headers, request) { Ok(request) => request, Err(_) => return Redirect::to("/login").into_response(), }; + + let mut definitions = state.definitions; match definitions.post_table_definition(request).await { Ok(response) if response.get_ref().success => { let location = format!("/admin?profile={profile_name}"); @@ -56,40 +87,67 @@ pub(crate) async fn create_table( return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); }; let mut response = StatusCode::SEE_OTHER.into_response(); - response.headers_mut().insert(header::LOCATION, location.clone()); + response + .headers_mut() + .insert(header::LOCATION, location.clone()); response.headers_mut().insert("hx-redirect", location); response } Ok(response) => { - let detail = if response.get_ref().sql.is_empty() { + page.error = Some(if response.get_ref().sql.is_empty() { "The backend did not create the table.".to_string() } else { response.get_ref().sql.clone() - }; - ( - StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_submission_error(&detail)), - ) - .into_response() + }); + (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response() + } + Err(error) => { + page.error = Some(error.message().to_string()); + (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response() } - Err(error) => ( - StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_submission_error(error.message())), - ) - .into_response(), } } -fn render_loaded(result: Result) -> Response { - match result { - Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), - Err(LoadError::Forbidden) => ( +/// Runs the pressed button against the draft. +/// +/// `refresh` covers the plain re-renders — picking a profile or a column type +/// changes which fields apply, which is the client's field-visibility rule. +fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { + let index = form.index.unwrap_or(0); + match form.action.as_str() { + "add-column" => match page.draft.add_column_from_inputs() { + Ok(status) => page.status = Some(status), + Err(message) => page.error = Some(message), + }, + "remove-column" => match page.draft.remove_column(index) { + Ok(status) => page.status = Some(status), + Err(message) => page.error = Some(message), + }, + "toggle-index" => page.draft.toggle_column_indexed(index), + "cycle-link" => page.draft.cycle_link_mode(index), + "toggle-display" => page.draft.toggle_row_display_candidate(index), + _ => {} + } +} + +fn reject_cross_site(headers: &HeaderMap) -> Option { + headers + .get("sec-fetch-site") + .is_some_and(|value| value == "cross-site") + .then(|| (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response()) +} + +fn load_error_response(error: LoadError) -> Response { + match error { + LoadError::Unauthenticated => Redirect::to("/login").into_response(), + LoadError::Forbidden => ( StatusCode::FORBIDDEN, - Html(ui::render_submission_error("Administrator access is required.")), + Html(ui::render_submission_error( + "Administrator access is required.", + )), ) .into_response(), - Err(LoadError::Backend(message)) => ( + LoadError::Backend(message) => ( StatusCode::BAD_GATEWAY, Html(ui::render_submission_error(&message)), ) diff --git a/web/src/pages/add_table/mod.rs b/web/src/pages/add_table/mod.rs index 8a1e8416..89f9e9f6 100644 --- a/web/src/pages/add_table/mod.rs +++ b/web/src/pages/add_table/mod.rs @@ -1,3 +1,4 @@ +mod draft; mod loader; mod logic; mod state; @@ -13,5 +14,6 @@ use crate::AppState; pub(crate) fn router() -> Router { Router::new() .route("/admin/tables/new", get(logic::new_table_page)) + .route("/admin/tables/builder", post(logic::update_builder)) .route("/admin/tables", post(logic::create_table)) } diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 4b1727bb..eff4e24a 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -1,164 +1,293 @@ -use crate::definitions::table_definition::{ - ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink, -}; +//! Wire format for the builder form, and the page state the templates read. +//! +//! HTTP is stateless, so the whole draft travels with every interaction: each +//! already-added column, link and display column is posted back as a set of +//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`) +//! decodes the repeats into `Vec`s, which `to_draft` zips back into a +//! [`TableDraft`]. + +use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft}; + +/// The `profile_name` option meaning "create a new profile too". +pub(crate) const NEW_PROFILE: &str = "__new__"; #[derive(Clone, Debug, Default, serde::Deserialize)] -pub(crate) struct CreateTableForm { +pub(crate) struct BuilderForm { + /// Which builder button was pressed. Empty on the initial page load. + #[serde(default)] + pub action: String, + /// Row the action applies to, for the per-row buttons. + #[serde(default)] + pub index: Option, + #[serde(default)] pub profile_name: String, #[serde(default)] + pub profile_name_input: String, + #[serde(default)] + pub accounting_currency: String, + #[serde(default)] pub table_name: String, #[serde(default)] - pub columns: String, - #[serde(default)] - pub indexed_columns: String, - #[serde(default)] - pub required_links: String, - #[serde(default)] - pub optional_links: String, - #[serde(default)] pub base_currency: String, + + // The pending column being described in the input panel. #[serde(default)] - pub row_display_columns: String, + pub column_name_input: String, + #[serde(default)] + pub column_type_input: String, + #[serde(default)] + pub temporal_type_input: String, + #[serde(default)] + pub gtin_type_input: String, + #[serde(default)] + pub column_indexing_input: String, + #[serde(default)] + pub column_quantity_ledger_input: String, + #[serde(default)] + pub column_rounding_input: String, + + // One entry per already-added column, in order. + #[serde(default)] + pub column_names: Vec, + #[serde(default)] + pub column_types: Vec, + #[serde(default)] + pub column_indexed: Vec, + #[serde(default)] + pub column_quantity_ledger: Vec, + #[serde(default)] + pub column_rounding: Vec, + + // One entry per link target offered by the profile, in order. + #[serde(default)] + pub link_tables: Vec, + #[serde(default)] + pub link_modes: Vec, + + #[serde(default)] + pub row_display_columns: Vec, } -pub(crate) struct AddTablePageState { - pub nav: crate::ui::Nav, - pub profiles: Vec, - pub form: CreateTableForm, - pub error: Option, +fn is_yes(value: &str) -> bool { + value.trim().eq_ignore_ascii_case("yes") } -impl CreateTableForm { - pub(crate) fn into_request(self) -> Result { - let profile_name = self.profile_name.trim().to_string(); - let table_name = self.table_name.trim().to_string(); - if profile_name.is_empty() { - return Err("Select a profile.".to_string()); - } - if table_name.is_empty() { - return Err("Enter a table name.".to_string()); - } +impl BuilderForm { + pub(crate) fn creating_new_profile(&self) -> bool { + self.profile_name == NEW_PROFILE + } - let indexed_columns = comma_separated(&self.indexed_columns); - let mut columns = Vec::new(); - let mut inline_indexes = Vec::new(); - for (index, line) in self.columns.lines().enumerate() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let mut parts = line.splitn(3, ':'); - let name = parts.next().unwrap_or_default().trim(); - let field_type = parts.next().unwrap_or_default().trim(); - let flags = parts.next().unwrap_or_default(); - if name.is_empty() || field_type.is_empty() { - return Err(format!( - "Column line {} must use `name: type`.", - index + 1 - )); - } - let flags = flags - .split(',') - .map(str::trim) - .filter(|flag| !flag.is_empty()) - .collect::>(); - if flags.contains(&"indexed") { - inline_indexes.push(name.to_string()); - } - let rounding = if flags.contains(&"half-up") { - MoneyRounding::HalfUp - } else { - MoneyRounding::None - }; - columns.push(ColumnDefinition { - name: name.to_string(), - field_type: field_type.to_string(), - rounding: rounding.into(), - quantity_ledger: flags.contains(&"quantity-ledger"), - }); - } - if columns.is_empty() { - return Err("Add at least one column.".to_string()); - } + /// Rebuilds the draft this form was rendered from. + /// + /// The column vectors are parallel, so a short one (a truncated or + /// tampered-with post) simply limits how many columns are reconstructed + /// rather than mis-pairing them. + pub(crate) fn to_draft(&self) -> TableDraft { + let creating_new_profile = self.creating_new_profile(); - let mut indexes = indexed_columns; - for name in inline_indexes { - if !indexes.contains(&name) { - indexes.push(name); - } - } + let column_count = [ + self.column_names.len(), + self.column_types.len(), + self.column_indexed.len(), + self.column_quantity_ledger.len(), + self.column_rounding.len(), + ] + .into_iter() + .min() + .unwrap_or(0); - let mut links = comma_separated(&self.required_links) - .into_iter() - .map(|linked_table_name| TableLink { - linked_table_name, - required: true, + let columns = (0..column_count) + .map(|index| ColumnDefinition { + name: self.column_names[index].clone(), + data_type: self.column_types[index].clone(), + indexed: is_yes(&self.column_indexed[index]), + quantity_ledger: is_yes(&self.column_quantity_ledger[index]), + money_mode: if self.column_rounding[index].trim() == MoneyMode::Rounded.label() { + MoneyMode::Rounded + } else { + MoneyMode::Exact + }, }) .collect::>(); - links.extend( - comma_separated(&self.optional_links) - .into_iter() - .map(|linked_table_name| TableLink { - linked_table_name, - required: false, - }), - ); - let has_money = columns.iter().any(|column| { - column.field_type.eq_ignore_ascii_case("money") - || column.field_type.eq_ignore_ascii_case("accounting") - }); - let base_currency = self.base_currency.trim().to_ascii_uppercase(); - if has_money && base_currency.is_empty() { - return Err("A base currency is required when a MONEY column is used.".to_string()); - } + let link_count = self.link_tables.len().min(self.link_modes.len()); + let links = (0..link_count) + .map(|index| LinkDefinition { + linked_table_name: self.link_tables[index].clone(), + mode: LinkMode::from_label(&self.link_modes[index]), + }) + .collect(); - Ok(PostTableDefinitionRequest { - accounting_currency: String::new(), - table_name, - links, + // Drop display columns whose column is gone, so a stale post cannot + // send a display column that no longer exists. + let row_display_columns = self + .row_display_columns + .iter() + .filter(|display| columns.iter().any(|column| &&column.name == display)) + .cloned() + .collect(); + + TableDraft { + profile_name: if creating_new_profile { + String::new() + } else { + self.profile_name.trim().to_string() + }, + profile_name_input: self.profile_name_input.clone(), + creating_new_profile, + accounting_currency: self.accounting_currency.clone(), + table_name: self.table_name.clone(), + base_currency: self.base_currency.clone(), + column_name_input: self.column_name_input.clone(), + column_type_input: self.column_type_input.clone(), + temporal_type_input: self.temporal_type_input.clone(), + gtin_type_input: self.gtin_type_input.clone(), + column_indexing_input: self.column_indexing_input.clone(), + column_quantity_ledger_input: self.column_quantity_ledger_input.clone(), + column_rounding_input: self.column_rounding_input.clone(), columns, - indexes, - profile_name, - base_currency: if has_money { base_currency } else { String::new() }, - row_display_columns: comma_separated(&self.row_display_columns), - }) + links, + row_display_columns, + // Filled in by the loader from the live profile tree, never by the + // client: it is what duplicate table names are checked against. + existing_profile_tables: Vec::new(), + } } } -fn comma_separated(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect() +/// What the page and the builder fragment render. +pub(crate) struct AddTablePageState { + pub nav: crate::ui::Nav, + pub profiles: Vec, + pub draft: TableDraft, + /// The outcome of the last builder action, if any. + pub status: Option, + pub error: Option, +} + +impl AddTablePageState { + /// The value the profile ` - - {% for profile in page.profiles %} - - {% endfor %} - - - - - - - - - - -
- {%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%} -
-
- Cancel - +
+ {% include "pages/add_table/builder.html" %}
+ + + {% for code in currency_codes %}{% endfor %} + {% endblock %} diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html new file mode 100644 index 00000000..2762eadc --- /dev/null +++ b/web/templates/pages/add_table/builder.html @@ -0,0 +1,237 @@ +{# + POST /admin/tables/builder — crate::pages::add_table::ui::BuilderFragment. + + The whole builder, which is also what `add_table.html` embeds on first load. + Every control posts the entire draft back to /admin/tables/builder and swaps + this markup in again, so the rules live on the server exactly as they do in + the TUI client. The hidden inputs in the columns, relations and display + sections are what carry the draft across requests. +#} +{% import "ui/alert.html" as alert %} + +{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%} +{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%} + +
+

Table

+
+ + + {% if page.draft.show_profile_name_input() %} + + {% endif %} + + {% if page.draft.show_accounting_currency() %} + + {% else %} + + {% endif %} + + + + {% if page.draft.show_base_currency() %} + + {% else %} + + {% endif %} +
+
+ +
+

Add a column

+
+ + + + {% if page.draft.show_temporal_type() %} + + {% endif %} + + {% if page.draft.show_gtin_type() %} + + {% endif %} + + {% if page.draft.show_rounding() %} + + {% endif %} + + + +
+ +
+ +
+

Columns {{ page.draft.columns.len() }}

+ {% if page.draft.columns.is_empty() %} +

No columns yet. Describe one above and press Add column.

+ {% else %} + + + + {% for column in page.draft.columns %} + + + + + + + + {% endfor %} + +
NameTypeIndexedOptions
{{ column.name }}{{ column.data_type }} + + + {% if column.quantity_ledger %}quantity ledger{% endif %} + {% if !column.option_label().is_empty() %}{{ column.option_label() }}{% endif %} + + +
+ {% endif %} + + {# The draft itself: one set of fields per column, in order. #} + {% for column in page.draft.columns %} + + + + + + {% endfor %} +
+ +
+

Relations

+ {% if page.draft.links.is_empty() %} +

No other tables in this profile to link to.

+ {% else %} +

Each active relation adds a <table>_id column.

+
    + {% for link in page.draft.links %} +
  • + + {{ link.linked_table_name }} +
  • + {% endfor %} +
+ {% endif %} + + {% for link in page.draft.links %} + + + {% endfor %} +
+ +
+

Row display columns

+

What identifies a row to users, in the order chosen. Pick id to clear the list.

+
    + {% for candidate in page.row_display_candidates() %} +
  • + + {{ candidate.name }} +
  • + {% endfor %} +
+ + {% for display in page.draft.row_display_columns %} + + {% endfor %} +
+ +
+

Table definition preview

+ + + + {% for row in page.draft.preview_rows() %} + + + + + + + + {% endfor %} + +
ColumnTypeOptionsSource
{{ row.mark }}{{ row.column }}{{ row.data_type }}{{ row.option }}{{ row.source }}
+
+ +
+ Cancel + +