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"]);
}
}