diff --git a/web/src/lib.rs b/web/src/lib.rs index 978cef6c..727c6298 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -8,6 +8,7 @@ use axum::{ }; mod pages; +mod schema; mod services; mod ui; mod analytics { @@ -136,6 +137,7 @@ fn router(state: AppState) -> Router { .merge(pages::analytics::router()) .merge(pages::login::router()) .merge(pages::admin::admin::router()) + .merge(pages::admin::table_definition::router()) .merge(pages::add_table::router()) .merge(pages::add_logic::router()) .merge(pages::add_validation::router()) @@ -231,6 +233,48 @@ mod tests { assert!(!body.contains("hx-post=\"/logout\"")); } + /// Every table-definition endpoint is mounted, and every one of them is + /// behind a session: without a cookie there is no request to sign, so each + /// answers with the redirect to the login page rather than a 404 or a call + /// to the backend. + #[tokio::test] + async fn the_table_definition_workspace_is_mounted_and_needs_a_session() { + for path in ["/admin/table-definition", "/admin/table-definition/workspace"] { + let (status, _) = get(path).await; + assert_eq!( + status, + axum::http::StatusCode::SEE_OTHER, + "{path} did not send an anonymous visitor to the login page" + ); + } + + for path in [ + "/admin/table-definition/columns", + "/admin/table-definition/columns/builder", + "/admin/table-definition/rename", + "/admin/table-definition/delete", + "/admin/table-definition/copy", + "/admin/table-definition/invoice-template", + ] { + let response = test_router() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("profile=billing&table=invoice")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + axum::http::StatusCode::SEE_OTHER, + "{path} did not send an anonymous visitor to the login page" + ); + } + } + #[tokio::test] async fn stylesheet_is_served_once_for_every_page() { let (status, body) = get("/static/app.css").await; diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index c73d90b9..0cc0999c 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -3,103 +3,21 @@ //! 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. +//! 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. +//! What a *column* may be is not here: that is [`crate::schema`], which the +//! append screen in `admin/table_definition` shares. This module is only what +//! is true of a table being created — its profile, its name, its links, and +//! what identifies one of its rows. -use crate::definitions::table_definition::{ - ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest, - TableLink as ProtoTableLink, +use crate::{ + definitions::table_definition::{ + PostTableDefinitionRequest, TableLink as ProtoTableLink, + }, + schema::{ColumnDraft, proto_columns, validate_identifier}, }; -/// 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", -]; - -/// Whether a column of this type declares a currency. -/// -/// Both MONEY and ACCOUNTING do, which is why this is a named predicate rather -/// than an inline comparison: written by hand, the ACCOUNTING half is easy to -/// forget, and forgetting it is silent — the currency is still stored and sent, -/// it just stops being validated or displayed. -pub(crate) fn carries_currency(field_type: &str) -> bool { - field_type.eq_ignore_ascii_case("money") || field_type.eq_ignore_ascii_case("accounting") -} - -/// 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] @@ -143,29 +61,6 @@ impl LinkMode { } } -#[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, - pub currency: String, -} - -impl ColumnDefinition { - /// The `option` cell of the preview, mirroring the client's preview table. - pub(crate) fn option_label(&self) -> String { - let has_currency = carries_currency(&self.data_type); - match (self.indexed, has_currency) { - (true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()), - (true, false) => "indexed".to_string(), - (false, true) => format!("{}, {}", self.currency, self.money_mode.label()), - (false, false) => String::new(), - } - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LinkDefinition { pub linked_table_name: String, @@ -193,17 +88,9 @@ pub(crate) struct TableDraft { pub table_name: 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 column_currency_input: String, + /// The column panel: the pending column and the ones already described. + pub columns: ColumnDraft, - 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. @@ -219,51 +106,11 @@ impl TableDraft { pub(crate) fn new() -> Self { Self { accounting_currency: "EUR".to_string(), - column_indexing_input: "no".to_string(), - column_quantity_ledger_input: "no".to_string(), - column_rounding_input: "none".to_string(), - column_currency_input: "EUR".to_string(), + columns: ColumnDraft::new(), ..Self::default() } } - // ---- pending-column input ------------------------------------------- - - pub(crate) fn pending_column_carries_currency(&self) -> bool { - carries_currency(self.column_type_input.trim()) - } - - 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 { @@ -274,111 +121,16 @@ impl TableDraft { 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() - } - - /// Currency and rounding both apply only to a money column. - pub(crate) fn show_money_options(&self) -> bool { - self.pending_column_carries_currency() - } - // ---- 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 has_currency = carries_currency(&column_type); - let currency = if has_currency { - normalize_currency_input(&self.column_currency_input)? - } else { - String::new() - }; - 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 has_currency { - MoneyMode::from_input(&self.column_rounding_input) - } else { - MoneyMode::Exact - }, - currency, - }); - - 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(); - self.column_currency_input = "EUR".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); + 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(); @@ -396,7 +148,12 @@ impl TableDraft { self.row_display_columns.clear(); return; } - let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else { + let Some(column) = self + .columns + .added + .get(index - 1) + .map(|column| column.name.clone()) + else { return; }; match self @@ -453,14 +210,6 @@ impl TableDraft { .any(|name| name == &self.table_name) } - 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 @@ -507,7 +256,7 @@ impl TableDraft { }); } - for column in &self.columns { + for column in &self.columns.added { rows.push(PreviewRow { mark: self .row_display_position(&column.name) @@ -556,29 +305,7 @@ impl TableDraft { 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)); - } - // The same rule the server enforces: required for a money column, - // forbidden for every other type. `add_column_from_inputs` already - // applies it, but a draft rebuilt from a posted form has not been - // through that path. - if carries_currency(&column.data_type) { - if let Err(error) = normalize_currency_input(&column.currency) { - return Err(format!("Column `{}`: {error}", column.name)); - } - } else if !column.currency.trim().is_empty() { - return Err(format!( - "Column `{}`: only MONEY and ACCOUNTING columns may declare a currency.", - column.name - )); - } - } - Ok(()) + self.columns.validate() } pub(crate) fn into_request(mut self) -> Result { @@ -588,21 +315,8 @@ impl TableDraft { 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, - currency: column.currency.clone(), - }) - .collect(), - indexes: self.selected_index_names(), + columns: proto_columns(&self.columns.added), + indexes: self.columns.selected_index_names(), links: self .links .iter() @@ -622,14 +336,6 @@ impl TableDraft { } } -fn normalize_currency_input(value: &str) -> Result { - let currency = value.trim().to_ascii_uppercase(); - if rusty_money::iso::find(¤cy).is_none() { - return Err("Currency must be a three-letter ISO-4217 code".to_string()); - } - Ok(currency) -} - pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { if !draft.creating_new_profile { return None; @@ -641,87 +347,16 @@ pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option 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::*; + use crate::schema::{ColumnDefinition, MoneyMode}; 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 { + draft.columns.added.push(ColumnDefinition { name: name.to_string(), data_type: data_type.to_string(), indexed: false, @@ -736,89 +371,6 @@ mod tests { 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_currency() { - let mut draft = TableDraft::new(); - draft.column_name_input = "total".to_string(); - draft.column_type_input = "money".to_string(); - draft.column_currency_input = "EU".to_string(); - assert!(draft.add_column_from_inputs().is_err()); - - draft.column_currency_input = "eur".to_string(); - draft.add_column_from_inputs().unwrap(); - assert_eq!(draft.columns[0].currency, "EUR"); - } - #[test] fn new_profile_accounting_currency_must_exist_in_the_iso_registry() { let mut draft = draft_with_column("total", "int"); @@ -833,40 +385,6 @@ mod tests { assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR"); } - /// `add_column_from_inputs` enforces this, but a draft rebuilt from a - /// posted form skips that path, so `validate` has to enforce it too. - #[test] - fn a_rebuilt_draft_is_still_held_to_the_currency_rule() { - let mut draft = draft_with_column("total", "money"); - draft.columns[0].currency = String::new(); - assert!(draft.validate().is_err()); - - draft.columns[0].currency = "XYZ".to_string(); - assert!(draft.validate().is_err()); - - draft.columns[0].currency = "EUR".to_string(); - assert!(draft.validate().is_ok()); - - // Forbidden on everything else, exactly as the server has it. - let mut draft = draft_with_column("note", "text"); - draft.columns[0].currency = "EUR".to_string(); - assert!(draft.validate().is_err()); - } - - #[test] - fn an_accounting_column_shows_its_currency_too() { - let mut draft = TableDraft::new(); - draft.column_type_input = "accounting".to_string(); - draft.column_currency_input = "czk".to_string(); - draft.add_column_from_inputs().unwrap(); - - assert_eq!(draft.columns[0].currency, "CZK"); - assert_eq!(draft.columns[0].option_label(), "CZK, exact"); - - draft.toggle_column_indexed(0); - assert_eq!(draft.columns[0].option_label(), "indexed, CZK, exact"); - } - #[test] fn existing_profile_sends_no_accounting_currency() { let draft = draft_with_column("total", "int"); @@ -892,6 +410,14 @@ mod tests { assert!(draft.validate().is_err()); } + #[test] + fn a_table_with_no_columns_is_refused() { + let mut draft = draft_with_column("total", "int"); + draft.remove_column(0).unwrap(); + + 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"); @@ -926,7 +452,7 @@ mod tests { #[test] fn row_display_columns_toggle_in_the_order_they_were_chosen() { let mut draft = draft_with_column("number", "text"); - draft.columns.push(ColumnDefinition { + draft.columns.added.push(ColumnDefinition { name: "issued_on".to_string(), data_type: "date".to_string(), indexed: false, @@ -979,9 +505,8 @@ mod tests { #[test] fn indexed_columns_become_the_index_list() { let mut draft = draft_with_column("number", "text"); - draft.toggle_column_indexed(0); + draft.columns.toggle_indexed(0); assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]); } } - diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 6bfc61d4..19a5957c 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -5,13 +5,16 @@ //! the TUI client applies in-process between keystrokes. use axum::{ - extract::State, + extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::Form; -use crate::{AppState, services::authenticated_request}; +use crate::{ + AppState, + services::{authenticated_request, reject_cross_site}, +}; use super::{ draft::TableDraft, @@ -20,9 +23,24 @@ use super::{ ui, }; +/// The profile the table-definition workspace hands over when it sends the +/// user here to create a table, so the picker opens on the right one. +#[derive(Debug, Default, serde::Deserialize)] +pub(crate) struct NewTableQuery { + #[serde(default)] + profile: String, +} + /// 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 { +pub(crate) async fn new_table_page( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let mut draft = TableDraft::new(); + draft.profile_name = query.profile.trim().to_string(); + + match load_page(state, &headers, draft, None, None).await { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(error) => load_error_response(error), } @@ -115,7 +133,7 @@ pub(crate) async fn create_table( 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() { + "add-column" => match page.draft.columns.add_from_inputs() { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, @@ -123,20 +141,13 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, - "toggle-index" => page.draft.toggle_column_indexed(index), + "toggle-index" => page.draft.columns.toggle_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(), diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 94a53241..a44484d7 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -5,8 +5,15 @@ //! 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`]. +//! +//! The column half of that form is the shared one — the field names here are +//! the same ones [`crate::schema::ColumnForm`] declares, and both are rebuilt +//! by [`crate::schema::columns_from_rows`], so the two screens that describe +//! columns cannot drift apart. -use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft}; +use crate::schema::{ColumnDraft, columns_from_rows}; + +use super::draft::{LinkDefinition, LinkMode, TableDraft}; /// The `profile_name` option meaning "create a new profile too". pub(crate) const NEW_PROFILE: &str = "__new__"; @@ -39,6 +46,10 @@ pub(crate) struct BuilderForm { #[serde(default)] pub gtin_type_input: String, #[serde(default)] + pub decimal_precision_input: String, + #[serde(default)] + pub decimal_scale_input: String, + #[serde(default)] pub column_indexing_input: String, #[serde(default)] pub column_quantity_ledger_input: String, @@ -71,49 +82,37 @@ pub(crate) struct BuilderForm { pub row_display_columns: Vec, } -fn is_yes(value: &str) -> bool { - value.trim().eq_ignore_ascii_case("yes") -} - impl BuilderForm { pub(crate) fn creating_new_profile(&self) -> bool { self.profile_name == NEW_PROFILE } /// 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 column_count = [ - self.column_names.len(), - self.column_types.len(), - self.column_indexed.len(), - self.column_quantity_ledger.len(), - self.column_rounding.len(), - self.column_currencies.len(), - ] - .into_iter() - .min() - .unwrap_or(0); - - 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 - }, - currency: self.column_currencies[index].clone(), - }) - .collect::>(); + let columns = ColumnDraft { + name_input: self.column_name_input.clone(), + type_input: self.column_type_input.clone(), + temporal_type_input: self.temporal_type_input.clone(), + gtin_type_input: self.gtin_type_input.clone(), + decimal_precision_input: self.decimal_precision_input.clone(), + decimal_scale_input: self.decimal_scale_input.clone(), + indexing_input: self.column_indexing_input.clone(), + quantity_ledger_input: self.column_quantity_ledger_input.clone(), + rounding_input: self.column_rounding_input.clone(), + currency_input: self.column_currency_input.clone(), + added: columns_from_rows( + &self.column_names, + &self.column_types, + &self.column_indexed, + &self.column_quantity_ledger, + &self.column_rounding, + &self.column_currencies, + ), + // The table is being created here, so ACCOUNTING is on the table. + accounting_allowed: true, + }; let link_count = self.link_tables.len().min(self.link_modes.len()); let links = (0..link_count) @@ -128,7 +127,7 @@ impl BuilderForm { let row_display_columns = self .row_display_columns .iter() - .filter(|display| columns.iter().any(|column| &&column.name == display)) + .filter(|display| columns.added.iter().any(|column| &&column.name == display)) .cloned() .collect(); @@ -142,14 +141,6 @@ impl BuilderForm { creating_new_profile, accounting_currency: self.accounting_currency.clone(), table_name: self.table_name.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(), - column_currency_input: self.column_currency_input.clone(), columns, links, row_display_columns, @@ -192,13 +183,18 @@ impl AddTablePageState { None }, }]; - candidates.extend(self.draft.columns.iter().enumerate().map(|(index, column)| { - RowDisplayCandidate { - index: index + 1, - name: column.name.clone(), - position: self.draft.row_display_position(&column.name), - } - })); + candidates.extend( + self.draft + .columns + .added + .iter() + .enumerate() + .map(|(index, column)| RowDisplayCandidate { + index: index + 1, + name: column.name.clone(), + position: self.draft.row_display_position(&column.name), + }), + ); candidates } } @@ -225,6 +221,7 @@ impl RowDisplayCandidate { #[cfg(test)] mod tests { use super::*; + use crate::schema::MoneyMode; fn posted_form() -> BuilderForm { BuilderForm { @@ -247,9 +244,9 @@ mod tests { fn round_trips_columns_links_and_display_columns() { let draft = posted_form().to_draft(); - assert_eq!(draft.columns.len(), 2); - assert!(draft.columns[0].indexed); - assert_eq!(draft.columns[1].money_mode, MoneyMode::Rounded); + assert_eq!(draft.columns.added.len(), 2); + assert!(draft.columns.added[0].indexed); + assert_eq!(draft.columns.added[1].money_mode, MoneyMode::Rounded); assert_eq!(draft.links[0].mode, LinkMode::Required); assert_eq!(draft.links[1].mode, LinkMode::None); assert_eq!(draft.row_display_columns, vec!["number"]); @@ -276,9 +273,9 @@ mod tests { let draft = form.to_draft(); - assert_eq!(draft.columns.len(), 1); - assert_eq!(draft.columns[0].name, "number"); - assert_eq!(draft.columns[0].data_type, "text"); + assert_eq!(draft.columns.added.len(), 1); + assert_eq!(draft.columns.added[0].name, "number"); + assert_eq!(draft.columns.added[0].data_type, "text"); } #[test] diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 8c5cec2a..e83c8554 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -1,12 +1,12 @@ use askama::Template; -use crate::ui::{Alert, Nav, render}; - -use super::{ - draft::{COLUMN_TYPES, CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES}, - state::AddTablePageState, +use crate::{ + schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES}, + ui::{Alert, Nav, render}, }; +use super::state::AddTablePageState; + /// GET /admin/tables/new — the page shell around the builder. #[derive(Template)] #[template(path = "pages/add_table/add_table.html")] @@ -34,7 +34,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String { render(&AddTablePage { nav: page.nav.clone(), page, - column_types: COLUMN_TYPES, + column_types: page.draft.columns.offered_types(), temporal_types: TEMPORAL_TYPES, gtin_types: GTIN_TYPES, currency_codes: CURRENCY_CODES, @@ -44,7 +44,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String { pub(crate) fn render_builder(page: &AddTablePageState) -> String { render(&BuilderFragment { page, - column_types: COLUMN_TYPES, + column_types: page.draft.columns.offered_types(), temporal_types: TEMPORAL_TYPES, gtin_types: GTIN_TYPES, }) @@ -60,13 +60,16 @@ pub(crate) fn render_submission_error(message: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::pages::add_table::draft::{ColumnDefinition, LinkMode, MoneyMode, TableDraft}; + use crate::{ + pages::add_table::draft::{LinkMode, TableDraft}, + schema::{ColumnDefinition, MoneyMode}, + }; fn page() -> AddTablePageState { let mut draft = TableDraft::new(); draft.profile_name = "billing".to_string(); draft.table_name = "invoice".to_string(); - draft.columns.push(ColumnDefinition { + draft.columns.added.push(ColumnDefinition { name: "number".to_string(), data_type: "text".to_string(), indexed: true, @@ -116,21 +119,41 @@ mod tests { assert!(html.contains(r#"