add table is better now

This commit is contained in:
Priec
2026-08-03 14:44:12 +02:00
parent c8afe99d79
commit 023f8c9dcb
11 changed files with 1799 additions and 212 deletions

View File

@@ -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<ColumnDefinition>,
pub links: Vec<LinkDefinition>,
/// 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<String>,
/// Tables already defined in the target profile — a new table may not
/// reuse one of these names.
pub existing_profile_tables: Vec<String>,
}
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<String> {
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(&gtin_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<String, String> {
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<String, String> {
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<String>) {
let previous_modes = self
.links
.iter()
.map(|link| (link.linked_table_name.clone(), link.mode))
.collect::<std::collections::HashMap<_, _>>();
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<String> {
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<usize> {
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<PreviewRow> {
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<PostTableDefinitionRequest, String> {
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<String> {
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<String> {
if !draft.creating_new_profile {
return None;
}
let currency = draft.accounting_currency.to_ascii_uppercase();
if rusty_money::iso::find(&currency).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<String> {
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<String> {
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::<Vec<_>>();
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"]);
}
}

View File

@@ -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<String>,
error: Option<String>,
) -> Result<AddTablePageState, LoadError> {
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::<Vec<_>>();
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,
})
}

View File

@@ -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<AppState>,
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<AppState>, 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<AppState>,
headers: HeaderMap,
Form(form): Form<BuilderForm>,
) -> 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<AppState>,
headers: HeaderMap,
Form(form): Form<CreateTableForm>,
Form(form): Form<BuilderForm>,
) -> 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<super::state::AddTablePageState, LoadError>) -> 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<Response> {
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)),
)

View File

@@ -1,3 +1,4 @@
mod draft;
mod loader;
mod logic;
mod state;
@@ -13,5 +14,6 @@ use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
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))
}

View File

@@ -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<usize>,
#[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<String>,
#[serde(default)]
pub column_types: Vec<String>,
#[serde(default)]
pub column_indexed: Vec<String>,
#[serde(default)]
pub column_quantity_ledger: Vec<String>,
#[serde(default)]
pub column_rounding: Vec<String>,
// One entry per link target offered by the profile, in order.
#[serde(default)]
pub link_tables: Vec<String>,
#[serde(default)]
pub link_modes: Vec<String>,
#[serde(default)]
pub row_display_columns: Vec<String>,
}
pub(crate) struct AddTablePageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
pub form: CreateTableForm,
pub error: Option<String>,
fn is_yes(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("yes")
}
impl CreateTableForm {
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
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<String>,
pub draft: TableDraft,
/// The outcome of the last builder action, if any.
pub status: Option<String>,
pub error: Option<String>,
}
impl AddTablePageState {
/// The value the profile `<select>` should show as chosen.
pub(crate) fn selected_profile(&self) -> &str {
if self.draft.creating_new_profile {
NEW_PROFILE
} else {
&self.draft.profile_name
}
}
/// Row-display candidates: `id` first, then every column, matching the
/// client's candidate list.
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
let mut candidates = vec![RowDisplayCandidate {
index: 0,
name: "id".to_string(),
position: if self.draft.row_display_columns.is_empty() {
Some(0)
} else {
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
}
}
pub(crate) struct RowDisplayCandidate {
pub index: usize,
pub name: String,
/// `Some(0)` marks `id` as chosen; `Some(n)` is a column's 1-based place.
pub position: Option<usize>,
}
impl RowDisplayCandidate {
/// The selection mark, matching the client: a tick for `id`, otherwise the
/// column's place in the display order.
pub(crate) fn mark(&self) -> String {
match self.position {
Some(0) => "[x]".to_string(),
Some(position) => format!("[{position}]"),
None => "[ ]".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_columns_indexes_links_and_money_options() {
let request = CreateTableForm {
profile_name: "accounting".into(),
fn posted_form() -> BuilderForm {
BuilderForm {
profile_name: "billing".into(),
table_name: "invoice".into(),
columns: "number: text:indexed\namount: money:half-up,quantity-ledger".into(),
required_links: "customer".into(),
base_currency: "eur".into(),
row_display_columns: "number, amount".into(),
column_names: vec!["number".into(), "total".into()],
column_types: vec!["text".into(), "money".into()],
column_indexed: vec!["yes".into(), "no".into()],
column_quantity_ledger: vec!["no".into(), "no".into()],
column_rounding: vec!["exact".into(), "half-up".into()],
link_tables: vec!["customer".into(), "project".into()],
link_modes: vec!["required".into(), "none".into()],
row_display_columns: vec!["number".into()],
..Default::default()
}
.into_request()
.unwrap();
}
assert_eq!(request.indexes, vec!["number"]);
assert_eq!(request.links[0].linked_table_name, "customer");
assert!(request.links[0].required);
assert_eq!(request.base_currency, "EUR");
assert_eq!(request.row_display_columns, vec!["number", "amount"]);
assert!(request.columns[1].quantity_ledger);
#[test]
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.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[1].mode, LinkMode::None);
assert_eq!(draft.row_display_columns, vec!["number"]);
assert!(!draft.creating_new_profile);
}
#[test]
fn the_new_profile_option_switches_to_the_typed_name() {
let mut form = posted_form();
form.profile_name = NEW_PROFILE.to_string();
form.profile_name_input = " bookkeeping ".to_string();
let draft = form.to_draft();
assert!(draft.creating_new_profile);
assert_eq!(draft.profile_name, "");
assert_eq!(draft.effective_profile_name(), "bookkeeping");
}
#[test]
fn mismatched_column_vectors_never_mis_pair() {
let mut form = posted_form();
form.column_types.pop();
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");
}
#[test]
fn display_columns_for_removed_columns_are_dropped() {
let mut form = posted_form();
form.row_display_columns = vec!["number".into(), "gone".into()];
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
}
#[test]
fn the_draft_never_trusts_the_posted_table_list() {
// `existing_profile_tables` is what duplicate-name checks read, so it
// must come from the server, not the form.
assert!(posted_form().to_draft().existing_profile_tables.is_empty());
}
}

View File

@@ -2,24 +2,154 @@ use askama::Template;
use crate::ui::{Alert, Nav, render};
use super::state::AddTablePageState;
use super::{
draft::{COLUMN_TYPES, CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
state::AddTablePageState,
};
/// GET /admin/tables/new
/// GET /admin/tables/new — the page shell around the builder.
#[derive(Template)]
#[template(path = "pages/add_table/add_table.html")]
struct AddTablePage<'a> {
nav: Nav,
page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
currency_codes: &'static [&'static str],
}
/// POST /admin/tables/builder — the `#builder` swap, which is the same markup
/// the page embeds, so one template serves both.
#[derive(Template)]
#[template(path = "pages/add_table/builder.html")]
struct BuilderFragment<'a> {
page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
}
pub(crate) fn render_page(page: &AddTablePageState) -> String {
render(&AddTablePage {
nav: page.nav.clone(),
page,
column_types: COLUMN_TYPES,
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
currency_codes: CURRENCY_CODES,
})
}
/// POST /admin/tables — the #submission-status swap.
pub(crate) fn render_builder(page: &AddTablePageState) -> String {
render(&BuilderFragment {
page,
column_types: COLUMN_TYPES,
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
})
}
/// Used when the page itself cannot be loaded (auth or backend failure).
pub(crate) fn render_submission_error(message: &str) -> String {
render(&Alert::error("Could not create the table", message))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::add_table::draft::{ColumnDefinition, LinkMode, MoneyMode, TableDraft};
fn page() -> AddTablePageState {
let mut draft = TableDraft::new();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.push(ColumnDefinition {
name: "number".to_string(),
data_type: "text".to_string(),
indexed: true,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
});
draft.set_available_relation_tables(vec!["customer".to_string()]);
draft.cycle_link_mode(0);
draft.toggle_row_display_candidate(1);
AddTablePageState {
nav: Nav::default(),
profiles: vec!["billing".to_string()],
draft,
status: None,
error: None,
}
}
/// `render` turns a template failure into an error string rather than
/// panicking, so the markup has to be asserted on.
#[test]
fn the_builder_carries_the_whole_draft_and_the_preview() {
let html = render_builder(&page());
assert!(!html.contains("Template error"), "{html}");
// Every part of the draft travels with the next request.
assert!(html.contains(r#"name="column_names" value="number""#));
assert!(html.contains(r#"name="column_indexed" value="yes""#));
assert!(html.contains(r#"name="link_tables" value="customer""#));
assert!(html.contains(r#"name="link_modes" value="optional""#));
assert!(html.contains(r#"name="row_display_columns" value="number""#));
// The preview shows the schema as it will exist.
assert!(html.contains("customer_id"));
assert!(html.contains("BIGSERIAL"));
assert!(html.contains("TIMESTAMPTZ"));
}
#[test]
fn the_page_offers_the_new_profile_option_and_the_currency_list() {
let html = render_page(&page());
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"value="__new__""#));
assert!(html.contains(r#"<datalist id="currency-codes">"#));
assert!(html.contains(r#"<option value="EUR">"#));
}
#[test]
fn conditional_fields_follow_the_pending_column_type() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="temporal_type_input""#));
state.draft.column_type_input = "temporal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="temporal_type_input""#));
assert!(!html.contains(r#"name="gtin_type_input""#));
state.draft.column_type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Money reveals rounding, and the base currency becomes editable.
state.draft.column_type_input = "money".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="column_rounding_input""#));
assert!(html.contains(r#"list="currency-codes""#));
}
#[test]
fn the_new_profile_fields_appear_only_for_a_new_profile() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="profile_name_input" value"#));
state.draft.creating_new_profile = true;
let html = render_builder(&state);
assert!(html.contains(r#"name="profile_name_input""#));
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
}
#[test]
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
let html = render_builder(&page());
assert!(html.contains(r#""action": "cycle-link", "index": "0""#));
assert!(html.contains("mode-optional"));
assert_eq!(LinkMode::Optional.label(), "optional");
}
}