web interface for table_definition improved
This commit is contained in:
@@ -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(>in_type)
|
||||
.then(|| format!("gtin_{gtin_type}"))
|
||||
}
|
||||
"" => None,
|
||||
_ => Some(column_type),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- field visibility (the same rules the TUI canvas applies) --------
|
||||
|
||||
pub(crate) fn show_profile_name_input(&self) -> bool {
|
||||
@@ -274,111 +121,16 @@ impl TableDraft {
|
||||
self.creating_new_profile
|
||||
}
|
||||
|
||||
pub(crate) fn show_temporal_type(&self) -> bool {
|
||||
self.is_temporal_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_gtin_type(&self) -> bool {
|
||||
self.is_gtin_column_input()
|
||||
}
|
||||
|
||||
/// Currency and rounding both apply only to a money column.
|
||||
pub(crate) fn show_money_options(&self) -> bool {
|
||||
self.pending_column_carries_currency()
|
||||
}
|
||||
|
||||
// ---- mutations -------------------------------------------------------
|
||||
|
||||
/// Appends the pending column, then clears the input panel.
|
||||
pub(crate) fn add_column_from_inputs(&mut self) -> Result<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(¤cy).is_none() {
|
||||
return Err("Currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
Ok(currency)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<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"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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""#));
|
||||
|
||||
@@ -61,6 +61,7 @@ mod tests {
|
||||
};
|
||||
let html = render_page(&page);
|
||||
for route in [
|
||||
"/admin/table-definition",
|
||||
"/admin/tables/new",
|
||||
"/admin/logic/new",
|
||||
"/admin/validation/new",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
196
web/src/pages/admin/table_definition/loader.rs
Normal file
196
web/src/pages/admin/table_definition/loader.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Reads everything the workspace shows.
|
||||
//!
|
||||
//! Three calls, in this order: the profile tree names the profiles and their
|
||||
//! tables, the profile details describe the selected table's columns and
|
||||
//! scripts, and the rename history explains how those columns got their names.
|
||||
//! Nothing here trusts the posted selection — a profile or table that is gone
|
||||
//! is dropped from the selection rather than reported as an error, because the
|
||||
//! commonest way to get here with a stale one is having just deleted it.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::{
|
||||
common::Empty,
|
||||
table_definition::{
|
||||
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
|
||||
},
|
||||
},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{
|
||||
DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState,
|
||||
TableDetailView, TableSummary,
|
||||
};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
mut inputs: PageInputs,
|
||||
) -> Result<TableDefinitionPageState, LoadError> {
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
if authorization.role != "admin" {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let mut definitions = state.definitions;
|
||||
let tree = definitions
|
||||
.get_profile_tree(
|
||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let profiles = tree
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| profile.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// A profile that no longer exists takes the table selection with it.
|
||||
if !profiles.contains(&inputs.selection.profile) {
|
||||
inputs.selection.profile.clear();
|
||||
inputs.selection.table.clear();
|
||||
}
|
||||
|
||||
let tables = tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == inputs.selection.profile)
|
||||
.map(|profile| {
|
||||
profile
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| TableSummary {
|
||||
name: table.name.clone(),
|
||||
table_kind: table.table_kind.clone(),
|
||||
depends_on: table.depends_on.clone(),
|
||||
row_display_columns: table.row_display_columns.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if !tables
|
||||
.iter()
|
||||
.any(|table| table.name == inputs.selection.table)
|
||||
{
|
||||
inputs.selection.table.clear();
|
||||
}
|
||||
|
||||
let detail = match inputs.selection.has_table() {
|
||||
true => {
|
||||
let request = GetProfileDetailsRequest {
|
||||
profile_name: inputs.selection.profile.clone(),
|
||||
};
|
||||
let details = definitions
|
||||
.get_profile_details(
|
||||
authenticated_request(headers, request)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
details
|
||||
.tables
|
||||
.into_iter()
|
||||
.find(|table| table.name == inputs.selection.table)
|
||||
.map(|table| TableDetailView {
|
||||
id: table.id,
|
||||
columns: table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let behavior = table.column_behaviors.get(&column.name);
|
||||
DetailColumn {
|
||||
name: column.name.clone(),
|
||||
field_type: column.field_type.clone(),
|
||||
currency: column.currency.clone(),
|
||||
quantity_ledger: column.quantity_ledger,
|
||||
rounded: column.rounding == i32::from(MoneyRounding::HalfUp),
|
||||
generated: behavior.is_some_and(|behavior| behavior.generated),
|
||||
read_only: behavior.is_some_and(|behavior| behavior.read_only),
|
||||
generated_from: behavior
|
||||
.map(|behavior| behavior.generated_from.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
scripts: table
|
||||
.scripts
|
||||
.into_iter()
|
||||
.map(|script| ScriptView {
|
||||
target_column: script.target_column,
|
||||
target_column_type: script.target_column_type,
|
||||
description: script.description,
|
||||
script: script.script,
|
||||
})
|
||||
.collect(),
|
||||
row_display_columns: table.row_display_columns,
|
||||
table_kind: table.table_kind,
|
||||
name: table.name,
|
||||
})
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
|
||||
// The history is per profile; a selected table narrows it to that table.
|
||||
let history = match inputs.selection.has_profile() {
|
||||
true => {
|
||||
let request = GetColumnAliasRenameHistoryRequest {
|
||||
profile_name: inputs.selection.profile.clone(),
|
||||
table_definition_id: detail.as_ref().map(|detail| detail.id),
|
||||
};
|
||||
definitions
|
||||
.get_column_alias_rename_history(
|
||||
authenticated_request(headers, request)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| RenameEntry {
|
||||
table_name: entry.table_name,
|
||||
old_column_name: entry.old_column_name,
|
||||
new_column_name: entry.new_column_name,
|
||||
created_at: entry.created_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
false => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(TableDefinitionPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
profiles,
|
||||
tables,
|
||||
detail,
|
||||
history,
|
||||
selection: inputs.selection,
|
||||
columns: inputs.columns,
|
||||
rename: inputs.rename,
|
||||
copy: inputs.copy,
|
||||
invoice: inputs.invoice,
|
||||
status: inputs.status,
|
||||
error: inputs.error,
|
||||
sql: inputs.sql,
|
||||
generated: inputs.generated,
|
||||
})
|
||||
}
|
||||
460
web/src/pages/admin/table_definition/logic.rs
Normal file
460
web/src/pages/admin/table_definition/logic.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
//! The workspace's request handlers — one per `TableDefinition` write.
|
||||
//!
|
||||
//! Every write answers with the whole workspace, re-read from the backend, so
|
||||
//! what the user sees after a change is the definition as it now is rather
|
||||
//! than the form they submitted. A refused write answers the same way but with
|
||||
//! 422 and the backend's own message, which `ui/base.html` swaps in because a
|
||||
//! 4xx still carries the explanation.
|
||||
//!
|
||||
//! The column panel is the exception: staging a column changes nothing on the
|
||||
//! server, so those interactions swap only the panel.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
definitions::table_definition::{
|
||||
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
|
||||
DeleteTableRequest, RenameColumnAliasRequest,
|
||||
},
|
||||
schema::{ColumnForm, proto_columns},
|
||||
services::{authenticated_request, reject_cross_site},
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader::load_page,
|
||||
state::{
|
||||
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
||||
RenameForm, Selection,
|
||||
},
|
||||
ui,
|
||||
};
|
||||
|
||||
/// GET /admin/table-definition
|
||||
pub(crate) async fn page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /admin/table-definition/workspace — the swap when the selection changes.
|
||||
pub(crate) async fn workspace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
||||
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns/builder — staging a column to append.
|
||||
///
|
||||
/// Nothing is written here; the panel is swapped back with the column added,
|
||||
/// removed, or its index toggled, exactly as the Add-table builder works.
|
||||
pub(crate) async fn update_columns(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
Form(form): Form<ColumnForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(selection);
|
||||
inputs.columns = form.to_draft(false);
|
||||
|
||||
let index = form.index.unwrap_or(0);
|
||||
match form.action.as_str() {
|
||||
"add-column" => match inputs.columns.add_from_inputs() {
|
||||
Ok(status) => inputs.status = Some(status),
|
||||
Err(message) => inputs.error = Some(message),
|
||||
},
|
||||
"remove-column" => {
|
||||
if let Err(message) = inputs.columns.remove(index) {
|
||||
inputs.error = Some(message);
|
||||
}
|
||||
}
|
||||
"toggle-index" => inputs.columns.toggle_indexed(index),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => Html(ui::render_column_panel(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns — AddTableColumns.
|
||||
pub(crate) async fn add_columns(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
Form(form): Form<ColumnForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(selection);
|
||||
inputs.columns = form.to_draft(false);
|
||||
|
||||
if !inputs.selection.has_table() {
|
||||
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
|
||||
}
|
||||
if inputs.columns.is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Describe at least one column before adding.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// The same checks the server runs, applied to a draft that may have been
|
||||
// rebuilt from a posted form rather than through the panel.
|
||||
if let Err(message) = inputs.columns.validate() {
|
||||
return refuse(state, headers, inputs, message).await;
|
||||
}
|
||||
|
||||
let request = AddTableColumnsRequest {
|
||||
profile_name: inputs.selection.profile.clone(),
|
||||
table_name: inputs.selection.table.clone(),
|
||||
columns: proto_columns(&inputs.columns.added),
|
||||
indexes: inputs.columns.selected_index_names(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.add_table_columns(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let added = inputs.columns.added.len();
|
||||
inputs.sql = Some(response.into_inner().sql);
|
||||
inputs.status = Some(format!(
|
||||
"{added} column{} added to `{}`.",
|
||||
if added == 1 { "" } else { "s" },
|
||||
inputs.selection.table
|
||||
));
|
||||
// The columns are the table's now, so the panel starts empty.
|
||||
inputs.columns = crate::schema::ColumnDraft::for_append();
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().sql;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not add the columns.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/rename — RenameColumnAlias.
|
||||
pub(crate) async fn rename_column(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<RenameForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: form.table.clone(),
|
||||
});
|
||||
inputs.rename = form.clone();
|
||||
|
||||
if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Choose a column and type its new name.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = RenameColumnAliasRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table.clone(),
|
||||
old_column_name: form.old_column_name.clone(),
|
||||
new_column_name: form.new_column_name.trim().to_string(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.rename_column_alias(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
inputs.status = Some(response.into_inner().message);
|
||||
inputs.rename = RenameForm {
|
||||
profile: form.profile,
|
||||
table: form.table,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not rename the column.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/delete — DeleteTable.
|
||||
pub(crate) async fn delete_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<DeleteForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: form.table.clone(),
|
||||
});
|
||||
|
||||
// The typed name is the whole guard: the drop is CASCADE, and it takes the
|
||||
// profile with it when this was its last table.
|
||||
if form.confirm_table_name.trim() != form.table || form.table.is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Type the table's name exactly to confirm the deletion.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = DeleteTableRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table.clone(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.delete_table(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
inputs.status = Some(response.into_inner().message);
|
||||
// Whatever was selected is gone; the loader drops it, and this
|
||||
// keeps the workspace from asking for it again.
|
||||
inputs.selection.table.clear();
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not delete the table.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/copy — CopyProfile.
|
||||
pub(crate) async fn copy_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CopyForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: String::new(),
|
||||
});
|
||||
inputs.copy = form.clone();
|
||||
|
||||
if form.target_profile_name.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Name the profile to copy into.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = CopyProfileRequest {
|
||||
source_profile_name: form.profile.clone(),
|
||||
target_profile_name: form.target_profile_name.trim().to_string(),
|
||||
table_names: form.table_names.clone(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.copy_profile(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let response = response.into_inner();
|
||||
inputs.status = Some(format!(
|
||||
"{} — {} table(s) and {} script(s) copied.",
|
||||
response.message, response.tables_copied, response.scripts_copied
|
||||
));
|
||||
inputs.copy = CopyForm {
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not copy the profile.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable.
|
||||
pub(crate) async fn create_from_invoice_template(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<InvoiceTemplateForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: String::new(),
|
||||
});
|
||||
inputs.invoice = form.clone();
|
||||
|
||||
if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"A table name and the template's source are both required.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = CreateInvoiceTemplateTableRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table_name.trim().to_string(),
|
||||
typst_source: form.typst_source.clone(),
|
||||
row_display_columns: form.display_columns(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.create_invoice_template_table(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let response = response.into_inner();
|
||||
inputs.status = Some(format!(
|
||||
"{} table(s) created from the template.",
|
||||
response.tables.len()
|
||||
));
|
||||
inputs.generated = response
|
||||
.tables
|
||||
.into_iter()
|
||||
.map(|table| GeneratedTableView {
|
||||
table_name: table.table_name,
|
||||
collection_path: table.collection_path,
|
||||
parent_table_name: table.parent_table_name,
|
||||
})
|
||||
.collect();
|
||||
inputs.invoice = InvoiceTemplateForm {
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(_) => {
|
||||
refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"The backend did not create the template's tables.".to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the workspace and answers with it.
|
||||
async fn respond(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
inputs: PageInputs,
|
||||
status: StatusCode,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a refused write: the workspace as it still is, plus the reason.
|
||||
async fn refuse(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
mut inputs: PageInputs,
|
||||
message: String,
|
||||
) -> Response {
|
||||
inputs.error = Some(message);
|
||||
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await
|
||||
}
|
||||
|
||||
fn load_error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_load_error(
|
||||
"Administrator access is required.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_load_error(&message)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
36
web/src/pages/admin/table_definition/mod.rs
Normal file
36
web/src/pages/admin/table_definition/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! The table-definition workspace: pick a profile and a table, then do
|
||||
//! anything the `TableDefinition` service offers to it.
|
||||
//!
|
||||
//! Creating a table is the one operation that lives elsewhere — it is a form
|
||||
//! long enough to want its own page, `pages/add_table` — and the workspace
|
||||
//! links to it with the chosen profile already filled in.
|
||||
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/table-definition", get(logic::page))
|
||||
.route("/admin/table-definition/workspace", get(logic::workspace))
|
||||
.route(
|
||||
"/admin/table-definition/columns/builder",
|
||||
post(logic::update_columns),
|
||||
)
|
||||
.route("/admin/table-definition/columns", post(logic::add_columns))
|
||||
.route("/admin/table-definition/rename", post(logic::rename_column))
|
||||
.route("/admin/table-definition/delete", post(logic::delete_table))
|
||||
.route("/admin/table-definition/copy", post(logic::copy_profile))
|
||||
.route(
|
||||
"/admin/table-definition/invoice-template",
|
||||
post(logic::create_from_invoice_template),
|
||||
)
|
||||
}
|
||||
346
web/src/pages/admin/table_definition/state.rs
Normal file
346
web/src/pages/admin/table_definition/state.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! What the workspace renders, and the wire formats its panels post.
|
||||
//!
|
||||
//! Every panel is a form of its own, and each one carries the selection it
|
||||
//! acts on in hidden fields, because the workspace is swapped whole on every
|
||||
//! write: the response is rebuilt from the live profile tree rather than from
|
||||
//! whatever the browser still had on screen.
|
||||
|
||||
use crate::schema::ColumnDraft;
|
||||
|
||||
/// The profile and table the workspace is pointed at. Arrives as a query
|
||||
/// string on the selector and on the column-panel endpoints, and as hidden
|
||||
/// fields on the panels that write.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table: String,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub(crate) fn has_profile(&self) -> bool {
|
||||
!self.profile.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn has_table(&self) -> bool {
|
||||
!self.table.is_empty()
|
||||
}
|
||||
|
||||
/// The query string the column panel posts back to, so the panel's own
|
||||
/// form does not have to carry the selection among its column fields.
|
||||
pub(crate) fn query(&self) -> String {
|
||||
format!("?profile={}&table={}", self.profile, self.table)
|
||||
}
|
||||
}
|
||||
|
||||
/// One table in the selected profile, from the profile tree.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TableSummary {
|
||||
pub name: String,
|
||||
pub table_kind: String,
|
||||
pub depends_on: Vec<String>,
|
||||
pub row_display_columns: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableSummary {
|
||||
/// System tables are backend-managed: the server refuses every write below
|
||||
/// on them, so the workspace does not offer the panels either.
|
||||
pub(crate) fn is_system(&self) -> bool {
|
||||
self.table_kind == "system"
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected table as the backend describes it, from `GetProfileDetails`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TableDetailView {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub table_kind: String,
|
||||
pub row_display_columns: Vec<String>,
|
||||
pub columns: Vec<DetailColumn>,
|
||||
pub scripts: Vec<ScriptView>,
|
||||
}
|
||||
|
||||
impl TableDetailView {
|
||||
pub(crate) fn is_system(&self) -> bool {
|
||||
self.table_kind == "system"
|
||||
}
|
||||
|
||||
/// Columns a rename may target. A generated companion (`phone_country`,
|
||||
/// the accounting fields) belongs to the column it was derived from, and
|
||||
/// the server refuses to rename one.
|
||||
pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> {
|
||||
self.columns
|
||||
.iter()
|
||||
.filter(|column| !column.generated)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DetailColumn {
|
||||
pub name: String,
|
||||
pub field_type: String,
|
||||
pub currency: String,
|
||||
pub quantity_ledger: bool,
|
||||
pub rounded: bool,
|
||||
pub generated: bool,
|
||||
pub read_only: bool,
|
||||
pub generated_from: String,
|
||||
}
|
||||
|
||||
impl DetailColumn {
|
||||
/// The badge list rendered under each column name.
|
||||
pub(crate) fn flags(&self) -> Vec<String> {
|
||||
let mut flags = Vec::new();
|
||||
if !self.currency.is_empty() {
|
||||
flags.push(self.currency.clone());
|
||||
}
|
||||
if self.rounded {
|
||||
flags.push("half-up".to_string());
|
||||
}
|
||||
if self.quantity_ledger {
|
||||
flags.push("quantity ledger".to_string());
|
||||
}
|
||||
if self.read_only {
|
||||
flags.push("read only".to_string());
|
||||
}
|
||||
if self.generated {
|
||||
flags.push(if self.generated_from.is_empty() {
|
||||
"generated".to_string()
|
||||
} else {
|
||||
format!("generated from {}", self.generated_from)
|
||||
});
|
||||
}
|
||||
flags
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ScriptView {
|
||||
pub target_column: String,
|
||||
pub target_column_type: String,
|
||||
pub description: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// One row of the stored rename history.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RenameEntry {
|
||||
pub table_name: String,
|
||||
pub old_column_name: String,
|
||||
pub new_column_name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// One physical table created from an invoice template.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedTableView {
|
||||
pub table_name: String,
|
||||
pub collection_path: String,
|
||||
pub parent_table_name: String,
|
||||
}
|
||||
|
||||
/// The rename panel's inputs, kept across a failed submit so the user does not
|
||||
/// retype them.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct RenameForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table: String,
|
||||
#[serde(default)]
|
||||
pub old_column_name: String,
|
||||
#[serde(default)]
|
||||
pub new_column_name: String,
|
||||
}
|
||||
|
||||
/// The copy-profile panel. An empty `table_names` copies the whole profile,
|
||||
/// which is what the backend takes an empty list to mean.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct CopyForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub target_profile_name: String,
|
||||
#[serde(default)]
|
||||
pub table_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// The invoice-template panel. `row_display_columns` is typed as a
|
||||
/// comma-separated list, because the columns do not exist yet — they are
|
||||
/// whatever the template's contract turns out to declare.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct InvoiceTemplateForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub typst_source: String,
|
||||
#[serde(default)]
|
||||
pub row_display_columns: String,
|
||||
}
|
||||
|
||||
impl InvoiceTemplateForm {
|
||||
pub(crate) fn display_columns(&self) -> Vec<String> {
|
||||
self.row_display_columns
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|column| !column.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The delete panel. The table name has to be typed back: `DeleteTable` drops
|
||||
/// the physical table with CASCADE and takes the profile with it when it was
|
||||
/// the last one, so a mis-click must not be enough.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct DeleteForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table: String,
|
||||
#[serde(default)]
|
||||
pub confirm_table_name: String,
|
||||
}
|
||||
|
||||
/// Everything the caller decides before the loader fills in the live data.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct PageInputs {
|
||||
pub selection: Selection,
|
||||
/// The columns staged for `AddTableColumns`.
|
||||
pub columns: ColumnDraft,
|
||||
pub rename: RenameForm,
|
||||
pub copy: CopyForm,
|
||||
pub invoice: InvoiceTemplateForm,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
/// The DDL a successful write reported.
|
||||
pub sql: Option<String>,
|
||||
/// The bundle a successful invoice-template creation reported.
|
||||
pub generated: Vec<GeneratedTableView>,
|
||||
}
|
||||
|
||||
impl PageInputs {
|
||||
pub(crate) fn for_selection(selection: Selection) -> Self {
|
||||
Self {
|
||||
selection,
|
||||
columns: ColumnDraft::for_append(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the templates read.
|
||||
pub(crate) struct TableDefinitionPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub selection: Selection,
|
||||
pub tables: Vec<TableSummary>,
|
||||
pub detail: Option<TableDetailView>,
|
||||
pub history: Vec<RenameEntry>,
|
||||
pub columns: ColumnDraft,
|
||||
pub rename: RenameForm,
|
||||
pub copy: CopyForm,
|
||||
pub invoice: InvoiceTemplateForm,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub sql: Option<String>,
|
||||
pub generated: Vec<GeneratedTableView>,
|
||||
}
|
||||
|
||||
impl TableDefinitionPageState {
|
||||
/// The selected table's summary, which is where its kind and dependencies
|
||||
/// come from.
|
||||
pub(crate) fn selected_table(&self) -> Option<&TableSummary> {
|
||||
self.tables
|
||||
.iter()
|
||||
.find(|table| table.name == self.selection.table)
|
||||
}
|
||||
|
||||
/// Whether the write panels apply. They need a table, and that table has
|
||||
/// to be one the server will let anyone but itself modify.
|
||||
pub(crate) fn table_is_writable(&self) -> bool {
|
||||
self.selected_table()
|
||||
.is_some_and(|table| !table.is_system())
|
||||
}
|
||||
|
||||
/// Tables offered as copy sources — all of them, since `CopyProfile`
|
||||
/// copies structure and a system table is structure too.
|
||||
pub(crate) fn copy_candidates(&self) -> &[TableSummary] {
|
||||
&self.tables
|
||||
}
|
||||
|
||||
pub(crate) fn copy_selected(&self, table_name: &str) -> bool {
|
||||
self.copy
|
||||
.table_names
|
||||
.iter()
|
||||
.any(|selected| selected == table_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_columns_are_split_and_trimmed() {
|
||||
let form = InvoiceTemplateForm {
|
||||
row_display_columns: " number , , issued_on ".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(form.display_columns(), vec!["number", "issued_on"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generated_column_says_what_it_came_from_and_cannot_be_renamed() {
|
||||
let detail = TableDetailView {
|
||||
id: 1,
|
||||
name: "contact".to_string(),
|
||||
table_kind: "dynamic".to_string(),
|
||||
row_display_columns: Vec::new(),
|
||||
scripts: Vec::new(),
|
||||
columns: vec![
|
||||
DetailColumn {
|
||||
name: "work_phone".to_string(),
|
||||
field_type: "phone".to_string(),
|
||||
currency: String::new(),
|
||||
quantity_ledger: false,
|
||||
rounded: false,
|
||||
generated: false,
|
||||
read_only: false,
|
||||
generated_from: String::new(),
|
||||
},
|
||||
DetailColumn {
|
||||
name: "work_phone_country".to_string(),
|
||||
field_type: "phone_country".to_string(),
|
||||
currency: String::new(),
|
||||
quantity_ledger: false,
|
||||
rounded: false,
|
||||
generated: true,
|
||||
read_only: true,
|
||||
generated_from: "work_phone".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let renameable = detail.renameable_columns();
|
||||
assert_eq!(renameable.len(), 1);
|
||||
assert_eq!(renameable[0].name, "work_phone");
|
||||
assert_eq!(
|
||||
detail.columns[1].flags(),
|
||||
vec!["read only", "generated from work_phone"]
|
||||
);
|
||||
}
|
||||
}
|
||||
306
web/src/pages/admin/table_definition/ui.rs
Normal file
306
web/src/pages/admin/table_definition/ui.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::{
|
||||
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
|
||||
ui::{Alert, Nav, render},
|
||||
};
|
||||
|
||||
use super::state::TableDefinitionPageState;
|
||||
|
||||
/// GET /admin/table-definition — the shell around the workspace.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/table_definition/table_definition.html")]
|
||||
struct TableDefinitionPage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a TableDefinitionPageState,
|
||||
column_types: &'static [&'static str],
|
||||
temporal_types: &'static [&'static str],
|
||||
gtin_types: &'static [&'static str],
|
||||
currency_codes: &'static [&'static str],
|
||||
/// False, as on the workspace fragment: the page embeds both, and the
|
||||
/// outcome is reported once, at the top.
|
||||
standalone_column_panel: bool,
|
||||
}
|
||||
|
||||
/// The `#table-definition-workspace` swap, which is the same markup the page
|
||||
/// embeds, so one template serves both.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/table_definition/workspace.html")]
|
||||
struct WorkspaceFragment<'a> {
|
||||
page: &'a TableDefinitionPageState,
|
||||
column_types: &'static [&'static str],
|
||||
temporal_types: &'static [&'static str],
|
||||
gtin_types: &'static [&'static str],
|
||||
/// False: the workspace shows the outcome of the last action itself, at
|
||||
/// the top, so the panel it embeds must not repeat it.
|
||||
standalone_column_panel: bool,
|
||||
}
|
||||
|
||||
/// The `#column-panel` swap, for staging a column without writing anything.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/table_definition/column_panel.html")]
|
||||
struct ColumnPanelFragment<'a> {
|
||||
page: &'a TableDefinitionPageState,
|
||||
column_types: &'static [&'static str],
|
||||
temporal_types: &'static [&'static str],
|
||||
gtin_types: &'static [&'static str],
|
||||
/// True: this is the whole response, so a refused column has nowhere else
|
||||
/// to be reported.
|
||||
standalone_column_panel: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
|
||||
render(&TableDefinitionPage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
// Asking the draft, so the picker can only ever offer what the draft
|
||||
// would accept — the accounting rule is stated once.
|
||||
column_types: page.columns.offered_types(),
|
||||
temporal_types: TEMPORAL_TYPES,
|
||||
gtin_types: GTIN_TYPES,
|
||||
currency_codes: CURRENCY_CODES,
|
||||
standalone_column_panel: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
|
||||
render(&WorkspaceFragment {
|
||||
page,
|
||||
// Asking the draft, so the picker can only ever offer what the draft
|
||||
// would accept — the accounting rule is stated once.
|
||||
column_types: page.columns.offered_types(),
|
||||
temporal_types: TEMPORAL_TYPES,
|
||||
gtin_types: GTIN_TYPES,
|
||||
standalone_column_panel: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
|
||||
render(&ColumnPanelFragment {
|
||||
page,
|
||||
// Asking the draft, so the picker can only ever offer what the draft
|
||||
// would accept — the accounting rule is stated once.
|
||||
column_types: page.columns.offered_types(),
|
||||
temporal_types: TEMPORAL_TYPES,
|
||||
gtin_types: GTIN_TYPES,
|
||||
standalone_column_panel: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Used when the workspace itself cannot be loaded. There is nothing left to
|
||||
/// render, so the dialog is what tells the user why.
|
||||
pub(crate) fn render_load_error(message: &str) -> String {
|
||||
render(&Alert::error("Table definition unavailable", message))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
pages::admin::table_definition::state::{
|
||||
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
|
||||
TableSummary,
|
||||
},
|
||||
schema::ColumnDraft,
|
||||
};
|
||||
|
||||
fn table(name: &str, kind: &str) -> TableSummary {
|
||||
TableSummary {
|
||||
name: name.to_string(),
|
||||
table_kind: kind.to_string(),
|
||||
depends_on: Vec::new(),
|
||||
row_display_columns: vec!["number".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn page() -> TableDefinitionPageState {
|
||||
TableDefinitionPageState {
|
||||
nav: Nav::default(),
|
||||
profiles: vec!["billing".to_string(), "payroll".to_string()],
|
||||
selection: Selection {
|
||||
profile: "billing".to_string(),
|
||||
table: "invoice".to_string(),
|
||||
},
|
||||
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
|
||||
detail: Some(TableDetailView {
|
||||
id: 7,
|
||||
name: "invoice".to_string(),
|
||||
table_kind: "dynamic".to_string(),
|
||||
row_display_columns: vec!["number".to_string()],
|
||||
scripts: Vec::new(),
|
||||
columns: vec![DetailColumn {
|
||||
name: "number".to_string(),
|
||||
field_type: "text".to_string(),
|
||||
currency: String::new(),
|
||||
quantity_ledger: false,
|
||||
rounded: false,
|
||||
generated: false,
|
||||
read_only: false,
|
||||
generated_from: String::new(),
|
||||
}],
|
||||
}),
|
||||
history: Vec::new(),
|
||||
columns: ColumnDraft::for_append(),
|
||||
rename: RenameForm::default(),
|
||||
copy: CopyForm::default(),
|
||||
invoice: InvoiceTemplateForm::default(),
|
||||
status: None,
|
||||
error: None,
|
||||
sql: None,
|
||||
generated: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the page: every write the service offers is reachable
|
||||
/// from the one screen, for the one selection.
|
||||
#[test]
|
||||
fn the_workspace_offers_every_table_definition_write() {
|
||||
let html = render_workspace(&page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
for route in [
|
||||
"/admin/table-definition/columns",
|
||||
"/admin/table-definition/rename",
|
||||
"/admin/table-definition/delete",
|
||||
"/admin/table-definition/copy",
|
||||
"/admin/table-definition/invoice-template",
|
||||
] {
|
||||
assert!(html.contains(route), "missing the {route} panel");
|
||||
}
|
||||
// And creating a table, which is the one write that has its own page.
|
||||
assert!(html.contains("/admin/tables/new?profile=billing"));
|
||||
}
|
||||
|
||||
/// The append panel posts the selection in its URL, so the column fields
|
||||
/// themselves stay exactly the ones the Add-table builder posts.
|
||||
#[test]
|
||||
fn the_column_panel_carries_the_selection_and_the_staged_columns() {
|
||||
let mut state = page();
|
||||
state.columns.name_input = "issued_on".to_string();
|
||||
state.columns.type_input = "temporal".to_string();
|
||||
state.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "total".to_string(),
|
||||
data_type: "money".to_string(),
|
||||
indexed: true,
|
||||
quantity_ledger: false,
|
||||
money_mode: crate::schema::MoneyMode::Rounded,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
// Escaped, because it is an attribute: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
// Escaped, because it is an attribute: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
assert!(html.contains("?profile=billing&table=invoice"));
|
||||
assert!(html.contains(r#"name="column_names" value="total""#));
|
||||
assert!(html.contains(r#"name="column_indexed" value="yes""#));
|
||||
assert!(html.contains(r#"name="column_currencies" value="EUR""#));
|
||||
// The pending type is temporal, so its subtype picker is showing.
|
||||
assert!(html.contains(r#"name="temporal_type_input""#));
|
||||
}
|
||||
|
||||
/// ACCOUNTING brings schema-managed companions with it, so the server only
|
||||
/// accepts it while the table is created. The panel must not offer it.
|
||||
#[test]
|
||||
fn the_append_panel_never_offers_an_accounting_column() {
|
||||
let html = render_column_panel(&page());
|
||||
|
||||
assert!(html.contains(r#"<option value="money""#));
|
||||
assert!(!html.contains(r#"<option value="accounting""#));
|
||||
}
|
||||
|
||||
/// A system table is the backend's own; every write below is refused for
|
||||
/// it, so the workspace shows the definition and stops there.
|
||||
#[test]
|
||||
fn a_system_table_is_readable_but_not_writable() {
|
||||
let mut state = page();
|
||||
state.selection.table = "accounts".to_string();
|
||||
state.detail = state.detail.map(|mut detail| {
|
||||
detail.name = "accounts".to_string();
|
||||
detail.table_kind = "system".to_string();
|
||||
detail
|
||||
});
|
||||
|
||||
let html = render_workspace(&state);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("backend-managed"));
|
||||
assert!(!html.contains("/admin/table-definition/delete"));
|
||||
assert!(!html.contains("/admin/table-definition/rename"));
|
||||
}
|
||||
|
||||
/// With only a profile chosen, the profile-wide panels are there and the
|
||||
/// table-wide ones are not.
|
||||
#[test]
|
||||
fn the_panels_follow_how_much_has_been_selected() {
|
||||
let mut state = page();
|
||||
state.selection.table = String::new();
|
||||
state.detail = None;
|
||||
|
||||
let html = render_workspace(&state);
|
||||
|
||||
assert!(html.contains("/admin/table-definition/copy"));
|
||||
assert!(html.contains("/admin/table-definition/invoice-template"));
|
||||
assert!(!html.contains("/admin/table-definition/delete"));
|
||||
|
||||
// With nothing chosen at all, only the profile picker is.
|
||||
state.selection.profile = String::new();
|
||||
state.tables.clear();
|
||||
let html = render_workspace(&state);
|
||||
assert!(!html.contains("/admin/table-definition/copy"));
|
||||
assert!(html.contains("Choose a profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
|
||||
let mut state = page();
|
||||
assert!(!render_workspace(&state).contains(r#"role="dialog""#));
|
||||
|
||||
state.error = Some("That column already exists.".to_string());
|
||||
let html = render_workspace(&state);
|
||||
assert!(html.contains(r#"role="dialog""#));
|
||||
// Once in the inline alert, once in the dialog — and not a third time
|
||||
// from the column panel the workspace embeds.
|
||||
assert_eq!(html.matches("That column already exists.").count(), 2);
|
||||
}
|
||||
|
||||
/// Staging a column swaps the panel alone, so a refused column has to be
|
||||
/// reported inside it or it is reported nowhere.
|
||||
#[test]
|
||||
fn a_refused_column_is_reported_in_the_panel_that_swapped() {
|
||||
let mut state = page();
|
||||
state.error = Some("Column name uses a reserved name.".to_string());
|
||||
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains(r#"role="dialog""#));
|
||||
assert_eq!(html.matches("Column name uses a reserved name.").count(), 2);
|
||||
}
|
||||
|
||||
/// A successful write reports the DDL the backend ran, which is the only
|
||||
/// place the user gets to see it.
|
||||
#[test]
|
||||
fn a_successful_write_shows_its_ddl() {
|
||||
let mut state = page();
|
||||
state.status = Some("1 column added to `invoice`.".to_string());
|
||||
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
|
||||
|
||||
let html = render_workspace(&state);
|
||||
|
||||
assert!(html.contains("ALTER TABLE"));
|
||||
assert!(html.contains("1 column added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_failure_answers_with_the_dialog() {
|
||||
let html = render_load_error("The backend is unreachable.");
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains(r#"role="dialog""#));
|
||||
assert!(html.contains("The backend is unreachable."));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user