web interface for table_definition improved

This commit is contained in:
Priec
2026-08-04 17:20:58 +02:00
parent c2002c7da3
commit 803207b1af
20 changed files with 2989 additions and 612 deletions

View File

@@ -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<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.
@@ -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<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 {
@@ -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<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 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<String, String> {
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<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
@@ -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<PostTableDefinitionRequest, String> {
@@ -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<String, String> {
let currency = value.trim().to_ascii_uppercase();
if rusty_money::iso::find(&currency).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<String> {
if !draft.creating_new_profile {
return None;
@@ -641,87 +347,16 @@ pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<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::*;
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"]);
}
}

View File

@@ -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<AppState>, headers: HeaderMap) -> Response {
match load_page(state, &headers, TableDraft::new(), None, None).await {
pub(crate) async fn new_table_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<NewTableQuery>,
) -> 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<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(),

View File

@@ -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<String>,
}
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::<Vec<_>>();
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]

View File

@@ -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#"<option value="EUR">"#));
}
/// Every type the server accepts has to be reachable from the picker, or
/// the web UI silently offers less than the backend does.
#[test]
fn the_type_picker_offers_the_parameterised_and_interval_types() {
let html = render_builder(&page());
for column_type in ["decimal", "duration", "period", "accounting"] {
assert!(
html.contains(&format!(r#"<option value="{column_type}""#)),
"the type picker is missing {column_type}"
);
}
}
#[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();
state.draft.columns.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();
state.draft.columns.type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Decimal reveals its precision and scale.
state.draft.columns.type_input = "decimal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="decimal_precision_input""#));
assert!(html.contains(r#"name="decimal_scale_input""#));
// Money reveals its currency and rounding inputs.
state.draft.column_type_input = "money".to_string();
state.draft.columns.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""#));