add table is better now
This commit is contained in:
@@ -6,6 +6,10 @@ edition = "2024"
|
||||
[dependencies]
|
||||
askama = "0.15.1"
|
||||
axum = "0.8"
|
||||
# `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and
|
||||
# the table builder posts one set of fields per already-added column.
|
||||
axum-extra = { version = "0.10", features = ["form"] }
|
||||
rusty-money = "0.5.0"
|
||||
prost = "0.14.4"
|
||||
prost-types = "0.14.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
939
web/src/pages/add_table/draft.rs
Normal file
939
web/src/pages/add_table/draft.rs
Normal file
@@ -0,0 +1,939 @@
|
||||
//! The Add-table draft and its rules.
|
||||
//!
|
||||
//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics
|
||||
//! with the terminal-specific parts removed (`ratatui` cursors, the canvas
|
||||
//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below —
|
||||
//! the type catalogue, canonicalisation, field visibility and validation —
|
||||
//! matches the client so the two frontends accept and reject exactly the same
|
||||
//! table definitions.
|
||||
//!
|
||||
//! Keeping it dependency-light is deliberate: this module is the candidate for
|
||||
//! extraction into a crate shared by `client`, `web` and `server`. Everything
|
||||
//! above [`TableDraft::into_request`] is already proto-free; that one method is
|
||||
//! the seam where a shared crate would hand back a plain draft for each
|
||||
//! frontend to map to its own generated request type.
|
||||
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest,
|
||||
TableLink as ProtoTableLink,
|
||||
};
|
||||
|
||||
/// Column types offered in the type picker. `temporal` and `gtin` are pickers
|
||||
/// of their own: neither is a storable type, each resolves to a subtype below.
|
||||
pub(crate) const COLUMN_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"temporal",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin",
|
||||
"bigint",
|
||||
];
|
||||
|
||||
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
|
||||
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
|
||||
|
||||
/// Every type the server accepts, i.e. what a canonicalised column may be.
|
||||
const CANONICAL_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"date",
|
||||
"time",
|
||||
"instant",
|
||||
"raw_datetime",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin_8",
|
||||
"gtin_12",
|
||||
"gtin_13",
|
||||
"gtin_14",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"bigint",
|
||||
];
|
||||
|
||||
/// Types a quantity-ledger column may use.
|
||||
fn quantity_ledger_type_allowed(field_type: &str) -> bool {
|
||||
matches!(field_type, "int" | "bigint" | "money")
|
||||
|| (field_type.starts_with("decimal(") && field_type.ends_with(')'))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum MoneyMode {
|
||||
#[default]
|
||||
Exact,
|
||||
Rounded,
|
||||
}
|
||||
|
||||
impl MoneyMode {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Exact => "exact",
|
||||
Self::Rounded => "half-up",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_input(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("half-up") {
|
||||
Self::Rounded
|
||||
} else {
|
||||
Self::Exact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum LinkMode {
|
||||
#[default]
|
||||
None,
|
||||
Optional,
|
||||
Required,
|
||||
}
|
||||
|
||||
impl LinkMode {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Optional => "optional",
|
||||
Self::Required => "required",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycles none → optional → required → none, as `Select` does in the TUI.
|
||||
pub(crate) fn next(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Optional,
|
||||
Self::Optional => Self::Required,
|
||||
Self::Required => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_label(value: &str) -> Self {
|
||||
match value.trim() {
|
||||
"optional" => Self::Optional,
|
||||
"required" => Self::Required,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
|
||||
pub(crate) fn is_required(self) -> bool {
|
||||
matches!(self, Self::Required)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ColumnDefinition {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub indexed: bool,
|
||||
pub quantity_ledger: bool,
|
||||
pub money_mode: MoneyMode,
|
||||
}
|
||||
|
||||
impl ColumnDefinition {
|
||||
/// The `option` cell of the preview, mirroring the client's preview table.
|
||||
pub(crate) fn option_label(&self) -> String {
|
||||
let is_money = self.data_type.eq_ignore_ascii_case("money");
|
||||
match (self.indexed, is_money) {
|
||||
(true, true) => format!("indexed, {}", self.money_mode.label()),
|
||||
(true, false) => "indexed".to_string(),
|
||||
(false, true) => self.money_mode.label().to_string(),
|
||||
(false, false) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LinkDefinition {
|
||||
pub linked_table_name: String,
|
||||
pub mode: LinkMode,
|
||||
}
|
||||
|
||||
/// One row of the "Table definition preview" — the schema as it will exist.
|
||||
pub(crate) struct PreviewRow {
|
||||
pub mark: String,
|
||||
pub column: String,
|
||||
pub data_type: String,
|
||||
pub option: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// The whole Add-table page state, minus presentation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct TableDraft {
|
||||
/// Profile this table belongs to when an existing one was picked.
|
||||
pub profile_name: String,
|
||||
/// Profile name typed in when creating a new profile.
|
||||
pub profile_name_input: String,
|
||||
pub creating_new_profile: bool,
|
||||
pub accounting_currency: String,
|
||||
|
||||
pub table_name: String,
|
||||
pub base_currency: String,
|
||||
|
||||
// The column-input panel: one pending column being described.
|
||||
pub column_name_input: String,
|
||||
pub column_type_input: String,
|
||||
pub temporal_type_input: String,
|
||||
pub gtin_type_input: String,
|
||||
pub column_indexing_input: String,
|
||||
pub column_quantity_ledger_input: String,
|
||||
pub column_rounding_input: String,
|
||||
|
||||
pub columns: Vec<ColumnDefinition>,
|
||||
pub links: Vec<LinkDefinition>,
|
||||
/// Columns identifying a row to users, in the order they are shown.
|
||||
/// Empty means rows are identified by their id alone.
|
||||
pub row_display_columns: Vec<String>,
|
||||
|
||||
/// Tables already defined in the target profile — a new table may not
|
||||
/// reuse one of these names.
|
||||
pub existing_profile_tables: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableDraft {
|
||||
/// A draft for a brand-new page load, matching the client's defaults.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
accounting_currency: "EUR".to_string(),
|
||||
base_currency: "EUR".to_string(),
|
||||
column_indexing_input: "no".to_string(),
|
||||
column_quantity_ledger_input: "no".to_string(),
|
||||
column_rounding_input: "none".to_string(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pending-column input -------------------------------------------
|
||||
|
||||
pub(crate) fn is_money_column_input(&self) -> bool {
|
||||
let value = self.column_type_input.trim();
|
||||
value.eq_ignore_ascii_case("money") || value.eq_ignore_ascii_case("accounting")
|
||||
}
|
||||
|
||||
pub(crate) fn is_temporal_column_input(&self) -> bool {
|
||||
self.column_type_input.trim().eq_ignore_ascii_case("temporal")
|
||||
}
|
||||
|
||||
pub(crate) fn is_gtin_column_input(&self) -> bool {
|
||||
self.column_type_input.trim().eq_ignore_ascii_case("gtin")
|
||||
}
|
||||
|
||||
/// The storable type the pending inputs describe, resolving the `temporal`
|
||||
/// and `gtin` pickers to their subtype. `None` while the choice is still
|
||||
/// incomplete.
|
||||
fn canonical_column_type_input(&self) -> Option<String> {
|
||||
let column_type = self.column_type_input.trim().to_ascii_lowercase();
|
||||
match column_type.as_str() {
|
||||
"temporal" => {
|
||||
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
|
||||
TEMPORAL_TYPES
|
||||
.contains(&temporal_type.as_str())
|
||||
.then_some(temporal_type)
|
||||
}
|
||||
"gtin" => {
|
||||
let gtin_type = self.gtin_type_input.trim();
|
||||
GTIN_TYPES
|
||||
.contains(>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 {
|
||||
self.creating_new_profile
|
||||
}
|
||||
|
||||
pub(crate) fn show_accounting_currency(&self) -> bool {
|
||||
self.creating_new_profile
|
||||
}
|
||||
|
||||
pub(crate) fn show_temporal_type(&self) -> bool {
|
||||
self.is_temporal_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_gtin_type(&self) -> bool {
|
||||
self.is_gtin_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_rounding(&self) -> bool {
|
||||
self.is_money_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_base_currency(&self) -> bool {
|
||||
self.is_money_column_input() || self.money_column_count() > 0
|
||||
}
|
||||
|
||||
// ---- mutations -------------------------------------------------------
|
||||
|
||||
/// Appends the pending column, then clears the input panel.
|
||||
pub(crate) fn add_column_from_inputs(&mut self) -> Result<String, String> {
|
||||
let Some(column_type) = self.canonical_column_type_input() else {
|
||||
return Err("Both a column name and a column type are required.".to_string());
|
||||
};
|
||||
|
||||
// An accounting column is always named `accounting`.
|
||||
let column_name = if column_type.eq_ignore_ascii_case("accounting") {
|
||||
"accounting".to_string()
|
||||
} else {
|
||||
self.column_name_input.trim().to_string()
|
||||
};
|
||||
|
||||
if column_name.is_empty() {
|
||||
return Err("Both a column name and a column type are required.".to_string());
|
||||
}
|
||||
if let Some(error) = validate_identifier(&column_name, "Column name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_field_type(&column_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.columns.iter().any(|column| column.name == column_name) {
|
||||
return Err(format!("A column named `{column_name}` already exists."));
|
||||
}
|
||||
|
||||
let quantity_ledger = self
|
||||
.column_quantity_ledger_input
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("yes");
|
||||
if quantity_ledger && !quantity_ledger_type_allowed(&column_type) {
|
||||
return Err(
|
||||
"Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let is_money = column_type.eq_ignore_ascii_case("money")
|
||||
|| column_type.eq_ignore_ascii_case("accounting");
|
||||
self.columns.push(ColumnDefinition {
|
||||
name: column_name.clone(),
|
||||
data_type: column_type,
|
||||
indexed: self
|
||||
.column_indexing_input
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("yes"),
|
||||
quantity_ledger,
|
||||
money_mode: if is_money {
|
||||
MoneyMode::from_input(&self.column_rounding_input)
|
||||
} else {
|
||||
MoneyMode::Exact
|
||||
},
|
||||
});
|
||||
|
||||
self.clear_column_inputs();
|
||||
Ok(format!("Column `{column_name}` added."))
|
||||
}
|
||||
|
||||
fn clear_column_inputs(&mut self) {
|
||||
self.column_name_input.clear();
|
||||
self.column_type_input.clear();
|
||||
self.temporal_type_input.clear();
|
||||
self.gtin_type_input.clear();
|
||||
self.column_indexing_input = "no".to_string();
|
||||
self.column_quantity_ledger_input = "no".to_string();
|
||||
self.column_rounding_input = "none".to_string();
|
||||
}
|
||||
|
||||
/// Removes one column, and drops it from the display columns with it.
|
||||
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
|
||||
if index >= self.columns.len() {
|
||||
return Err("That column no longer exists.".to_string());
|
||||
}
|
||||
let removed = self.columns.remove(index);
|
||||
self.row_display_columns
|
||||
.retain(|display| display != &removed.name);
|
||||
Ok(format!("Column `{}` removed.", removed.name))
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_column_indexed(&mut self, index: usize) {
|
||||
if let Some(column) = self.columns.get_mut(index) {
|
||||
column.indexed = !column.indexed;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_link_mode(&mut self, index: usize) {
|
||||
if let Some(link) = self.links.get_mut(index) {
|
||||
link.mode = link.mode.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or removes one display-column candidate.
|
||||
///
|
||||
/// Index 0 is `id`, which is not a display column of its own: choosing it
|
||||
/// clears the list, since an empty list already means "identified by id".
|
||||
/// Any other index toggles that column, appending so the order columns were
|
||||
/// chosen in is the order they are shown in.
|
||||
pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) {
|
||||
if index == 0 {
|
||||
self.row_display_columns.clear();
|
||||
return;
|
||||
}
|
||||
let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.row_display_columns
|
||||
.iter()
|
||||
.position(|display| *display == column)
|
||||
{
|
||||
Some(position) => {
|
||||
self.row_display_columns.remove(position);
|
||||
}
|
||||
None => self.row_display_columns.push(column),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the link list from the tables available in the target profile,
|
||||
/// keeping whatever mode each surviving link already had.
|
||||
pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec<String>) {
|
||||
let previous_modes = self
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| (link.linked_table_name.clone(), link.mode))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
self.links = table_names
|
||||
.into_iter()
|
||||
.filter(|table_name| table_name != &self.table_name)
|
||||
.map(|linked_table_name| LinkDefinition {
|
||||
mode: previous_modes
|
||||
.get(&linked_table_name)
|
||||
.copied()
|
||||
.unwrap_or(LinkMode::None),
|
||||
linked_table_name,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
// ---- derived state ---------------------------------------------------
|
||||
|
||||
/// The profile name to validate and persist: the typed input while creating
|
||||
/// a new profile, otherwise the profile that was picked.
|
||||
pub(crate) fn effective_profile_name(&self) -> String {
|
||||
if self.creating_new_profile {
|
||||
self.profile_name_input.trim().to_string()
|
||||
} else {
|
||||
self.profile_name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn table_name_conflicts(&self) -> bool {
|
||||
!self.table_name.is_empty()
|
||||
&& self
|
||||
.existing_profile_tables
|
||||
.iter()
|
||||
.any(|name| name == &self.table_name)
|
||||
}
|
||||
|
||||
pub(crate) fn money_column_count(&self) -> usize {
|
||||
self.columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
column.data_type.eq_ignore_ascii_case("money")
|
||||
|| column.data_type.eq_ignore_ascii_case("accounting")
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(crate) fn selected_index_names(&self) -> Vec<String> {
|
||||
self.columns
|
||||
.iter()
|
||||
.filter(|column| column.indexed)
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Position of `column` among the display columns, counting from 1.
|
||||
pub(crate) fn row_display_position(&self, column: &str) -> Option<usize> {
|
||||
self.row_display_columns
|
||||
.iter()
|
||||
.position(|display| display == column)
|
||||
.map(|index| index + 1)
|
||||
}
|
||||
|
||||
/// The schema as it will exist: system columns, relation columns, then the
|
||||
/// user's own. Mirrors the client's preview pane.
|
||||
pub(crate) fn preview_rows(&self) -> Vec<PreviewRow> {
|
||||
let mut rows = vec![
|
||||
PreviewRow {
|
||||
mark: if self.row_display_columns.is_empty() {
|
||||
"[x]".to_string()
|
||||
} else {
|
||||
"[ ]".to_string()
|
||||
},
|
||||
column: "id".to_string(),
|
||||
data_type: "BIGSERIAL".to_string(),
|
||||
option: "primary key".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "deleted".to_string(),
|
||||
data_type: "BOOLEAN".to_string(),
|
||||
option: "default false".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
for link in self.links.iter().filter(|link| link.mode.is_active()) {
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: format!("{}_id", link.linked_table_name),
|
||||
data_type: "BIGINT".to_string(),
|
||||
option: if link.mode.is_required() {
|
||||
"required".to_string()
|
||||
} else {
|
||||
"optional".to_string()
|
||||
},
|
||||
source: "relation".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for column in &self.columns {
|
||||
rows.push(PreviewRow {
|
||||
mark: self
|
||||
.row_display_position(&column.name)
|
||||
.map(|position| format!("[{position}]"))
|
||||
.unwrap_or_else(|| "[ ]".to_string()),
|
||||
column: column.name.clone(),
|
||||
data_type: column.data_type.clone(),
|
||||
option: column.option_label(),
|
||||
source: "user".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "created_at".to_string(),
|
||||
data_type: "TIMESTAMPTZ".to_string(),
|
||||
option: "current time".to_string(),
|
||||
source: "system".to_string(),
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
// ---- validation and submission ---------------------------------------
|
||||
|
||||
/// Every check the client runs before it will save.
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
let profile_name = self.effective_profile_name();
|
||||
if self.creating_new_profile && profile_name.is_empty() {
|
||||
return Err("Enter a name for the new profile.".to_string());
|
||||
}
|
||||
if let Some(error) = validate_identifier(&profile_name, "Profile name", false) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_accounting_currency(self) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.table_name_conflicts() {
|
||||
return Err(format!(
|
||||
"A table named `{}` already exists in profile `{}`.",
|
||||
self.table_name, profile_name
|
||||
));
|
||||
}
|
||||
if self.columns.is_empty() {
|
||||
return Err("Add at least one column before saving.".to_string());
|
||||
}
|
||||
for column in &self.columns {
|
||||
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_field_type(&column.data_type) {
|
||||
return Err(format!("Column `{}`: {error}", column.name));
|
||||
}
|
||||
}
|
||||
if let Some(error) = validate_base_currency(self) {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
|
||||
self.table_name = self.table_name.trim().to_string();
|
||||
self.validate()?;
|
||||
|
||||
Ok(PostTableDefinitionRequest {
|
||||
table_name: self.table_name.clone(),
|
||||
profile_name: self.effective_profile_name(),
|
||||
columns: self
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| ProtoColumnDefinition {
|
||||
name: column.name.clone(),
|
||||
field_type: column.data_type.clone(),
|
||||
rounding: match column.money_mode {
|
||||
MoneyMode::Rounded => MoneyRounding::HalfUp.into(),
|
||||
MoneyMode::Exact => MoneyRounding::None.into(),
|
||||
},
|
||||
quantity_ledger: column.quantity_ledger,
|
||||
})
|
||||
.collect(),
|
||||
indexes: self.selected_index_names(),
|
||||
links: self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| link.mode.is_active())
|
||||
.map(|link| ProtoTableLink {
|
||||
linked_table_name: link.linked_table_name.clone(),
|
||||
required: link.mode.is_required(),
|
||||
})
|
||||
.collect(),
|
||||
base_currency: if self.money_column_count() == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
self.base_currency.trim().to_ascii_uppercase()
|
||||
},
|
||||
accounting_currency: if self.creating_new_profile {
|
||||
self.accounting_currency.trim().to_ascii_uppercase()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
row_display_columns: self.row_display_columns.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option<String> {
|
||||
if draft.money_column_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
let currency = draft.base_currency.trim();
|
||||
if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Some("Base currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
|
||||
if !draft.creating_new_profile {
|
||||
return None;
|
||||
}
|
||||
let currency = draft.accounting_currency.to_ascii_uppercase();
|
||||
if rusty_money::iso::find(¤cy).is_none() {
|
||||
return Some("Accounting currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// PostgreSQL identifier rules, plus the names this schema reserves.
|
||||
pub(crate) fn validate_identifier(
|
||||
value: &str,
|
||||
label: &str,
|
||||
reject_table_reserved: bool,
|
||||
) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return Some(format!("{label} cannot be empty."));
|
||||
}
|
||||
if value != value.trim() {
|
||||
return Some(format!("{label} cannot start or end with a space."));
|
||||
}
|
||||
if value.starts_with('_') {
|
||||
return Some(format!("{label} cannot start with an underscore."));
|
||||
}
|
||||
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Some(format!("{label} cannot start with a number."));
|
||||
}
|
||||
if value.len() > 63 {
|
||||
return Some(format!("{label} cannot be longer than 63 characters."));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_')
|
||||
{
|
||||
return Some(format!(
|
||||
"{label} may only use lowercase letters, digits and underscores."
|
||||
));
|
||||
}
|
||||
if reject_table_reserved
|
||||
&& (value == "id"
|
||||
|| value == "deleted"
|
||||
|| value == "created_at"
|
||||
|| value == "row_revision"
|
||||
|| value.ends_with("_id"))
|
||||
{
|
||||
return Some(format!("{label} uses a reserved name."));
|
||||
}
|
||||
if !reject_table_reserved
|
||||
&& (value == "public" || value == "information_schema" || value.starts_with("pg_"))
|
||||
{
|
||||
return Some("That profile name is reserved by PostgreSQL.".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn validate_field_type(field_type: &str) -> Option<String> {
|
||||
let field_type = field_type.to_lowercase();
|
||||
if CANONICAL_TYPES.contains(&field_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("`{field_type}` is not a valid field type."))
|
||||
}
|
||||
|
||||
/// ISO-4217 codes offered as currency suggestions, matching the client's list.
|
||||
pub(crate) const CURRENCY_CODES: &[&str] = &[
|
||||
"EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM",
|
||||
"BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN",
|
||||
"BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK",
|
||||
"DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF",
|
||||
"GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD",
|
||||
"JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP",
|
||||
"LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR",
|
||||
"MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB",
|
||||
"PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD",
|
||||
"SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW",
|
||||
"UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD",
|
||||
"XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK",
|
||||
"ZWL",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.profile_name = "billing".to_string();
|
||||
draft.table_name = "invoice".to_string();
|
||||
draft.columns.push(ColumnDefinition {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
});
|
||||
draft
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_and_gtin_pickers_resolve_to_canonical_types() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "occurred_at".to_string();
|
||||
draft.column_type_input = "temporal".to_string();
|
||||
|
||||
// Incomplete while no subtype is chosen.
|
||||
assert!(draft.canonical_column_type_input().is_none());
|
||||
assert!(draft.show_temporal_type());
|
||||
|
||||
draft.temporal_type_input = "raw_datetime".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
assert_eq!(draft.columns[0].data_type, "raw_datetime");
|
||||
// Inputs are cleared for the next column.
|
||||
assert!(draft.temporal_type_input.is_empty());
|
||||
|
||||
draft.column_name_input = "barcode".to_string();
|
||||
draft.column_type_input = "gtin".to_string();
|
||||
draft.gtin_type_input = "13".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
assert_eq!(draft.columns[1].data_type, "gtin_13");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_identifiers_and_types_are_refused_at_add_time() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_type_input = "text".to_string();
|
||||
|
||||
draft.column_name_input = "Total".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
draft.column_name_input = "customer_id".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
draft.column_name_input = "created_at".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_name_input = "total".to_string();
|
||||
draft.column_type_input = "timestamptz".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_type_input = "text".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_ok());
|
||||
// Duplicates are refused too.
|
||||
draft.column_name_input = "total".to_string();
|
||||
draft.column_type_input = "text".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantity_ledger_requires_a_numeric_type() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "note".to_string();
|
||||
draft.column_type_input = "text".to_string();
|
||||
draft.column_quantity_ledger_input = "yes".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_type_input = "int".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_ok());
|
||||
assert!(draft.columns[0].quantity_ledger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_column_is_always_named_accounting() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "whatever".to_string();
|
||||
draft.column_type_input = "accounting".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
|
||||
assert_eq!(draft.columns[0].name, "accounting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_columns_require_a_valid_base_currency() {
|
||||
let mut draft = draft_with_column("total", "money");
|
||||
draft.base_currency = "EU".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.base_currency = "eur".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
assert_eq!(draft.into_request().unwrap().base_currency, "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_profile_accounting_currency_must_exist_in_the_iso_registry() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.creating_new_profile = true;
|
||||
draft.profile_name_input = "billing".to_string();
|
||||
|
||||
draft.accounting_currency = "AAA".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.accounting_currency = "eur".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_profile_sends_no_accounting_currency() {
|
||||
let draft = draft_with_column("total", "int");
|
||||
let request = draft.into_request().unwrap();
|
||||
|
||||
assert_eq!(request.accounting_currency, "");
|
||||
assert_eq!(request.profile_name, "billing");
|
||||
// No money column, so no base currency either.
|
||||
assert_eq!(request.base_currency, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_table_names_in_the_profile_are_refused() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.existing_profile_tables = vec!["invoice".to_string()];
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_profile_names_are_refused() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.profile_name = "pg_catalog".to_string();
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_keep_their_mode_when_the_table_list_is_reloaded() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.set_available_relation_tables(vec!["customer".into(), "project".into()]);
|
||||
draft.cycle_link_mode(0); // none -> optional
|
||||
draft.cycle_link_mode(0); // optional -> required
|
||||
|
||||
draft.set_available_relation_tables(vec![
|
||||
"customer".into(),
|
||||
"project".into(),
|
||||
"address".into(),
|
||||
]);
|
||||
|
||||
assert_eq!(draft.links[0].mode, LinkMode::Required);
|
||||
assert_eq!(draft.links[2].mode, LinkMode::None);
|
||||
|
||||
let request = draft.into_request().unwrap();
|
||||
assert_eq!(request.links.len(), 1);
|
||||
assert_eq!(request.links[0].linked_table_name, "customer");
|
||||
assert!(request.links[0].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_never_links_to_itself() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]);
|
||||
|
||||
assert_eq!(draft.links.len(), 1);
|
||||
assert_eq!(draft.links[0].linked_table_name, "customer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_display_columns_toggle_in_the_order_they_were_chosen() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.columns.push(ColumnDefinition {
|
||||
name: "issued_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
});
|
||||
|
||||
draft.toggle_row_display_candidate(2); // issued_on
|
||||
draft.toggle_row_display_candidate(1); // number
|
||||
assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]);
|
||||
assert_eq!(draft.row_display_position("number"), Some(2));
|
||||
|
||||
// Index 0 is `id`: choosing it clears the list.
|
||||
draft.toggle_row_display_candidate(0);
|
||||
assert!(draft.row_display_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_column_drops_it_from_the_display_columns() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.toggle_row_display_candidate(1);
|
||||
assert_eq!(draft.row_display_columns, vec!["number"]);
|
||||
|
||||
draft.remove_column(0).unwrap();
|
||||
assert!(draft.row_display_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_shows_system_relation_and_user_columns() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.set_available_relation_tables(vec!["customer".into()]);
|
||||
draft.cycle_link_mode(0);
|
||||
|
||||
let rows = draft.preview_rows();
|
||||
let columns = rows
|
||||
.iter()
|
||||
.map(|row| row.column.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
columns,
|
||||
vec!["id", "deleted", "customer_id", "number", "created_at"]
|
||||
);
|
||||
assert_eq!(rows[2].option, "optional");
|
||||
// No display column chosen, so `id` identifies the row.
|
||||
assert_eq!(rows[0].mark, "[x]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_columns_become_the_index_list() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.toggle_column_indexed(0);
|
||||
|
||||
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::common::Empty,
|
||||
AppState, auth::GetAuthorizationRequest, definitions::common::Empty,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{AddTablePageState, CreateTableForm};
|
||||
use super::{draft::TableDraft, state::AddTablePageState};
|
||||
|
||||
/// Loads everything the builder needs around the draft: the profiles that can
|
||||
/// be picked, and — for whichever profile the table will belong to — the tables
|
||||
/// that are link targets and the names the new table may not reuse.
|
||||
///
|
||||
/// The link targets and reserved names always come from the live profile tree,
|
||||
/// never from the posted form, so they cannot be spoofed by a crafted request.
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
form: CreateTableForm,
|
||||
mut draft: TableDraft,
|
||||
status: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> Result<AddTablePageState, LoadError> {
|
||||
let authorization_request =
|
||||
authenticated_request(headers, GetAuthorizationRequest {}).map_err(|_| LoadError::Unauthenticated)?;
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
@@ -38,10 +43,41 @@ pub(crate) async fn load_page(
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let effective_profile = draft.effective_profile_name();
|
||||
match tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == effective_profile)
|
||||
{
|
||||
// An existing profile: its tables are the link targets, and their
|
||||
// names are reserved against duplicate table creation.
|
||||
Some(profile) => {
|
||||
let table_names = profile
|
||||
.tables
|
||||
.iter()
|
||||
.filter(|table| table.name != "accounts")
|
||||
.map(|table| table.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
draft.existing_profile_tables = table_names.clone();
|
||||
draft.set_available_relation_tables(table_names);
|
||||
}
|
||||
// A brand-new (or not-yet-named) profile has nothing to link to.
|
||||
None => {
|
||||
draft.existing_profile_tables.clear();
|
||||
draft.links.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AddTablePageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(),
|
||||
form,
|
||||
profiles: tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|profile| profile.name)
|
||||
.collect(),
|
||||
draft,
|
||||
status,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,54 +1,85 @@
|
||||
//! The builder's request handlers.
|
||||
//!
|
||||
//! Every interaction posts the whole draft and swaps the whole builder back,
|
||||
//! so the server stays the single owner of the draft's rules — the same ones
|
||||
//! the TUI client applies in-process between keystrokes.
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{AppState, services::authenticated_request};
|
||||
|
||||
use super::{
|
||||
draft::TableDraft,
|
||||
loader::{LoadError, load_page},
|
||||
state::CreateTableForm,
|
||||
state::{AddTablePageState, BuilderForm},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn new_table_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await)
|
||||
/// GET /admin/tables/new
|
||||
pub(crate) async fn new_table_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match load_page(state, &headers, TableDraft::new(), None, None).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/tables/builder — every button and every `change` in the builder.
|
||||
pub(crate) async fn update_builder(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<BuilderForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut page = match load_page(state, &headers, form.to_draft(), None, None).await {
|
||||
Ok(page) => page,
|
||||
Err(error) => return load_error_response(error),
|
||||
};
|
||||
|
||||
apply_action(&mut page, &form);
|
||||
Html(ui::render_builder(&page)).into_response()
|
||||
}
|
||||
|
||||
/// POST /admin/tables — create the table.
|
||||
pub(crate) async fn create_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateTableForm>,
|
||||
Form(form): Form<BuilderForm>,
|
||||
) -> Response {
|
||||
let submitted_form = form.clone();
|
||||
if let Err(error) = load_page(state.clone(), &headers, form.clone(), None).await {
|
||||
return render_loaded(Err(error));
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
if headers
|
||||
.get("sec-fetch-site")
|
||||
.is_some_and(|value| value == "cross-site")
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response();
|
||||
}
|
||||
let request = match form.into_request() {
|
||||
|
||||
let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await {
|
||||
Ok(page) => page,
|
||||
Err(error) => return load_error_response(error),
|
||||
};
|
||||
|
||||
// The draft is validated here with exactly the checks the client runs
|
||||
// before it will save; the server re-validates authoritatively.
|
||||
let request = match page.draft.clone().into_request() {
|
||||
Ok(request) => request,
|
||||
Err(message) => {
|
||||
return render_loaded(
|
||||
load_page(state, &headers, submitted_form, Some(message)).await,
|
||||
);
|
||||
page.error = Some(message);
|
||||
return (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page)))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let profile_name = request.profile_name.clone();
|
||||
let mut definitions = state.definitions;
|
||||
let request = match authenticated_request(&headers, request) {
|
||||
Ok(request) => request,
|
||||
Err(_) => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions;
|
||||
match definitions.post_table_definition(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let location = format!("/admin?profile={profile_name}");
|
||||
@@ -56,40 +87,67 @@ pub(crate) async fn create_table(
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
response.headers_mut().insert(header::LOCATION, location.clone());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::LOCATION, location.clone());
|
||||
response.headers_mut().insert("hx-redirect", location);
|
||||
response
|
||||
}
|
||||
Ok(response) => {
|
||||
let detail = if response.get_ref().sql.is_empty() {
|
||||
page.error = Some(if response.get_ref().sql.is_empty() {
|
||||
"The backend did not create the table.".to_string()
|
||||
} else {
|
||||
response.get_ref().sql.clone()
|
||||
};
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_submission_error(&detail)),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
|
||||
}
|
||||
Err(error) => {
|
||||
page.error = Some(error.message().to_string());
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
|
||||
}
|
||||
Err(error) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_submission_error(error.message())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loaded(result: Result<super::state::AddTablePageState, LoadError>) -> Response {
|
||||
match result {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (
|
||||
/// Runs the pressed button against the draft.
|
||||
///
|
||||
/// `refresh` covers the plain re-renders — picking a profile or a column type
|
||||
/// changes which fields apply, which is the client's field-visibility rule.
|
||||
fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
|
||||
let index = form.index.unwrap_or(0);
|
||||
match form.action.as_str() {
|
||||
"add-column" => match page.draft.add_column_from_inputs() {
|
||||
Ok(status) => page.status = Some(status),
|
||||
Err(message) => page.error = Some(message),
|
||||
},
|
||||
"remove-column" => match page.draft.remove_column(index) {
|
||||
Ok(status) => page.status = Some(status),
|
||||
Err(message) => page.error = Some(message),
|
||||
},
|
||||
"toggle-index" => page.draft.toggle_column_indexed(index),
|
||||
"cycle-link" => page.draft.cycle_link_mode(index),
|
||||
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_cross_site(headers: &HeaderMap) -> Option<Response> {
|
||||
headers
|
||||
.get("sec-fetch-site")
|
||||
.is_some_and(|value| value == "cross-site")
|
||||
.then(|| (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response())
|
||||
}
|
||||
|
||||
fn load_error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_submission_error("Administrator access is required.")),
|
||||
Html(ui::render_submission_error(
|
||||
"Administrator access is required.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
Err(LoadError::Backend(message)) => (
|
||||
LoadError::Backend(message) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_submission_error(&message)),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod draft;
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
@@ -13,5 +14,6 @@ use crate::AppState;
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/tables/new", get(logic::new_table_page))
|
||||
.route("/admin/tables/builder", post(logic::update_builder))
|
||||
.route("/admin/tables", post(logic::create_table))
|
||||
}
|
||||
|
||||
@@ -1,164 +1,293 @@
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink,
|
||||
};
|
||||
//! Wire format for the builder form, and the page state the templates read.
|
||||
//!
|
||||
//! HTTP is stateless, so the whole draft travels with every interaction: each
|
||||
//! already-added column, link and display column is posted back as a set of
|
||||
//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`)
|
||||
//! decodes the repeats into `Vec`s, which `to_draft` zips back into a
|
||||
//! [`TableDraft`].
|
||||
|
||||
use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft};
|
||||
|
||||
/// The `profile_name` option meaning "create a new profile too".
|
||||
pub(crate) const NEW_PROFILE: &str = "__new__";
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct CreateTableForm {
|
||||
pub(crate) struct BuilderForm {
|
||||
/// Which builder button was pressed. Empty on the initial page load.
|
||||
#[serde(default)]
|
||||
pub action: String,
|
||||
/// Row the action applies to, for the per-row buttons.
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
#[serde(default)]
|
||||
pub profile_name_input: String,
|
||||
#[serde(default)]
|
||||
pub accounting_currency: String,
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: String,
|
||||
#[serde(default)]
|
||||
pub indexed_columns: String,
|
||||
#[serde(default)]
|
||||
pub required_links: String,
|
||||
#[serde(default)]
|
||||
pub optional_links: String,
|
||||
#[serde(default)]
|
||||
pub base_currency: String,
|
||||
|
||||
// The pending column being described in the input panel.
|
||||
#[serde(default)]
|
||||
pub row_display_columns: String,
|
||||
pub column_name_input: String,
|
||||
#[serde(default)]
|
||||
pub column_type_input: String,
|
||||
#[serde(default)]
|
||||
pub temporal_type_input: String,
|
||||
#[serde(default)]
|
||||
pub gtin_type_input: String,
|
||||
#[serde(default)]
|
||||
pub column_indexing_input: String,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger_input: String,
|
||||
#[serde(default)]
|
||||
pub column_rounding_input: String,
|
||||
|
||||
// One entry per already-added column, in order.
|
||||
#[serde(default)]
|
||||
pub column_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_indexed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_rounding: Vec<String>,
|
||||
|
||||
// One entry per link target offered by the profile, in order.
|
||||
#[serde(default)]
|
||||
pub link_tables: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub link_modes: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub row_display_columns: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub form: CreateTableForm,
|
||||
pub error: Option<String>,
|
||||
fn is_yes(value: &str) -> bool {
|
||||
value.trim().eq_ignore_ascii_case("yes")
|
||||
}
|
||||
|
||||
impl CreateTableForm {
|
||||
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> {
|
||||
let profile_name = self.profile_name.trim().to_string();
|
||||
let table_name = self.table_name.trim().to_string();
|
||||
if profile_name.is_empty() {
|
||||
return Err("Select a profile.".to_string());
|
||||
}
|
||||
if table_name.is_empty() {
|
||||
return Err("Enter a table name.".to_string());
|
||||
}
|
||||
impl BuilderForm {
|
||||
pub(crate) fn creating_new_profile(&self) -> bool {
|
||||
self.profile_name == NEW_PROFILE
|
||||
}
|
||||
|
||||
let indexed_columns = comma_separated(&self.indexed_columns);
|
||||
let mut columns = Vec::new();
|
||||
let mut inline_indexes = Vec::new();
|
||||
for (index, line) in self.columns.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.splitn(3, ':');
|
||||
let name = parts.next().unwrap_or_default().trim();
|
||||
let field_type = parts.next().unwrap_or_default().trim();
|
||||
let flags = parts.next().unwrap_or_default();
|
||||
if name.is_empty() || field_type.is_empty() {
|
||||
return Err(format!(
|
||||
"Column line {} must use `name: type`.",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let flags = flags
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if flags.contains(&"indexed") {
|
||||
inline_indexes.push(name.to_string());
|
||||
}
|
||||
let rounding = if flags.contains(&"half-up") {
|
||||
MoneyRounding::HalfUp
|
||||
} else {
|
||||
MoneyRounding::None
|
||||
};
|
||||
columns.push(ColumnDefinition {
|
||||
name: name.to_string(),
|
||||
field_type: field_type.to_string(),
|
||||
rounding: rounding.into(),
|
||||
quantity_ledger: flags.contains(&"quantity-ledger"),
|
||||
});
|
||||
}
|
||||
if columns.is_empty() {
|
||||
return Err("Add at least one column.".to_string());
|
||||
}
|
||||
/// Rebuilds the draft this form was rendered from.
|
||||
///
|
||||
/// The column vectors are parallel, so a short one (a truncated or
|
||||
/// tampered-with post) simply limits how many columns are reconstructed
|
||||
/// rather than mis-pairing them.
|
||||
pub(crate) fn to_draft(&self) -> TableDraft {
|
||||
let creating_new_profile = self.creating_new_profile();
|
||||
|
||||
let mut indexes = indexed_columns;
|
||||
for name in inline_indexes {
|
||||
if !indexes.contains(&name) {
|
||||
indexes.push(name);
|
||||
}
|
||||
}
|
||||
let column_count = [
|
||||
self.column_names.len(),
|
||||
self.column_types.len(),
|
||||
self.column_indexed.len(),
|
||||
self.column_quantity_ledger.len(),
|
||||
self.column_rounding.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut links = comma_separated(&self.required_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: true,
|
||||
let columns = (0..column_count)
|
||||
.map(|index| ColumnDefinition {
|
||||
name: self.column_names[index].clone(),
|
||||
data_type: self.column_types[index].clone(),
|
||||
indexed: is_yes(&self.column_indexed[index]),
|
||||
quantity_ledger: is_yes(&self.column_quantity_ledger[index]),
|
||||
money_mode: if self.column_rounding[index].trim() == MoneyMode::Rounded.label() {
|
||||
MoneyMode::Rounded
|
||||
} else {
|
||||
MoneyMode::Exact
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
links.extend(
|
||||
comma_separated(&self.optional_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let has_money = columns.iter().any(|column| {
|
||||
column.field_type.eq_ignore_ascii_case("money")
|
||||
|| column.field_type.eq_ignore_ascii_case("accounting")
|
||||
});
|
||||
let base_currency = self.base_currency.trim().to_ascii_uppercase();
|
||||
if has_money && base_currency.is_empty() {
|
||||
return Err("A base currency is required when a MONEY column is used.".to_string());
|
||||
}
|
||||
let link_count = self.link_tables.len().min(self.link_modes.len());
|
||||
let links = (0..link_count)
|
||||
.map(|index| LinkDefinition {
|
||||
linked_table_name: self.link_tables[index].clone(),
|
||||
mode: LinkMode::from_label(&self.link_modes[index]),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(PostTableDefinitionRequest {
|
||||
accounting_currency: String::new(),
|
||||
table_name,
|
||||
links,
|
||||
// Drop display columns whose column is gone, so a stale post cannot
|
||||
// send a display column that no longer exists.
|
||||
let row_display_columns = self
|
||||
.row_display_columns
|
||||
.iter()
|
||||
.filter(|display| columns.iter().any(|column| &&column.name == display))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
TableDraft {
|
||||
profile_name: if creating_new_profile {
|
||||
String::new()
|
||||
} else {
|
||||
self.profile_name.trim().to_string()
|
||||
},
|
||||
profile_name_input: self.profile_name_input.clone(),
|
||||
creating_new_profile,
|
||||
accounting_currency: self.accounting_currency.clone(),
|
||||
table_name: self.table_name.clone(),
|
||||
base_currency: self.base_currency.clone(),
|
||||
column_name_input: self.column_name_input.clone(),
|
||||
column_type_input: self.column_type_input.clone(),
|
||||
temporal_type_input: self.temporal_type_input.clone(),
|
||||
gtin_type_input: self.gtin_type_input.clone(),
|
||||
column_indexing_input: self.column_indexing_input.clone(),
|
||||
column_quantity_ledger_input: self.column_quantity_ledger_input.clone(),
|
||||
column_rounding_input: self.column_rounding_input.clone(),
|
||||
columns,
|
||||
indexes,
|
||||
profile_name,
|
||||
base_currency: if has_money { base_currency } else { String::new() },
|
||||
row_display_columns: comma_separated(&self.row_display_columns),
|
||||
})
|
||||
links,
|
||||
row_display_columns,
|
||||
// Filled in by the loader from the live profile tree, never by the
|
||||
// client: it is what duplicate table names are checked against.
|
||||
existing_profile_tables: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
/// What the page and the builder fragment render.
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub draft: TableDraft,
|
||||
/// The outcome of the last builder action, if any.
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AddTablePageState {
|
||||
/// The value the profile `<select>` should show as chosen.
|
||||
pub(crate) fn selected_profile(&self) -> &str {
|
||||
if self.draft.creating_new_profile {
|
||||
NEW_PROFILE
|
||||
} else {
|
||||
&self.draft.profile_name
|
||||
}
|
||||
}
|
||||
|
||||
/// Row-display candidates: `id` first, then every column, matching the
|
||||
/// client's candidate list.
|
||||
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
|
||||
let mut candidates = vec![RowDisplayCandidate {
|
||||
index: 0,
|
||||
name: "id".to_string(),
|
||||
position: if self.draft.row_display_columns.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}];
|
||||
candidates.extend(self.draft.columns.iter().enumerate().map(|(index, column)| {
|
||||
RowDisplayCandidate {
|
||||
index: index + 1,
|
||||
name: column.name.clone(),
|
||||
position: self.draft.row_display_position(&column.name),
|
||||
}
|
||||
}));
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RowDisplayCandidate {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
/// `Some(0)` marks `id` as chosen; `Some(n)` is a column's 1-based place.
|
||||
pub position: Option<usize>,
|
||||
}
|
||||
|
||||
impl RowDisplayCandidate {
|
||||
/// The selection mark, matching the client: a tick for `id`, otherwise the
|
||||
/// column's place in the display order.
|
||||
pub(crate) fn mark(&self) -> String {
|
||||
match self.position {
|
||||
Some(0) => "[x]".to_string(),
|
||||
Some(position) => format!("[{position}]"),
|
||||
None => "[ ]".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_columns_indexes_links_and_money_options() {
|
||||
let request = CreateTableForm {
|
||||
profile_name: "accounting".into(),
|
||||
fn posted_form() -> BuilderForm {
|
||||
BuilderForm {
|
||||
profile_name: "billing".into(),
|
||||
table_name: "invoice".into(),
|
||||
columns: "number: text:indexed\namount: money:half-up,quantity-ledger".into(),
|
||||
required_links: "customer".into(),
|
||||
base_currency: "eur".into(),
|
||||
row_display_columns: "number, amount".into(),
|
||||
column_names: vec!["number".into(), "total".into()],
|
||||
column_types: vec!["text".into(), "money".into()],
|
||||
column_indexed: vec!["yes".into(), "no".into()],
|
||||
column_quantity_ledger: vec!["no".into(), "no".into()],
|
||||
column_rounding: vec!["exact".into(), "half-up".into()],
|
||||
link_tables: vec!["customer".into(), "project".into()],
|
||||
link_modes: vec!["required".into(), "none".into()],
|
||||
row_display_columns: vec!["number".into()],
|
||||
..Default::default()
|
||||
}
|
||||
.into_request()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(request.indexes, vec!["number"]);
|
||||
assert_eq!(request.links[0].linked_table_name, "customer");
|
||||
assert!(request.links[0].required);
|
||||
assert_eq!(request.base_currency, "EUR");
|
||||
assert_eq!(request.row_display_columns, vec!["number", "amount"]);
|
||||
assert!(request.columns[1].quantity_ledger);
|
||||
#[test]
|
||||
fn round_trips_columns_links_and_display_columns() {
|
||||
let draft = posted_form().to_draft();
|
||||
|
||||
assert_eq!(draft.columns.len(), 2);
|
||||
assert!(draft.columns[0].indexed);
|
||||
assert_eq!(draft.columns[1].money_mode, MoneyMode::Rounded);
|
||||
assert_eq!(draft.links[0].mode, LinkMode::Required);
|
||||
assert_eq!(draft.links[1].mode, LinkMode::None);
|
||||
assert_eq!(draft.row_display_columns, vec!["number"]);
|
||||
assert!(!draft.creating_new_profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_profile_option_switches_to_the_typed_name() {
|
||||
let mut form = posted_form();
|
||||
form.profile_name = NEW_PROFILE.to_string();
|
||||
form.profile_name_input = " bookkeeping ".to_string();
|
||||
|
||||
let draft = form.to_draft();
|
||||
|
||||
assert!(draft.creating_new_profile);
|
||||
assert_eq!(draft.profile_name, "");
|
||||
assert_eq!(draft.effective_profile_name(), "bookkeeping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_column_vectors_never_mis_pair() {
|
||||
let mut form = posted_form();
|
||||
form.column_types.pop();
|
||||
|
||||
let draft = form.to_draft();
|
||||
|
||||
assert_eq!(draft.columns.len(), 1);
|
||||
assert_eq!(draft.columns[0].name, "number");
|
||||
assert_eq!(draft.columns[0].data_type, "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_columns_for_removed_columns_are_dropped() {
|
||||
let mut form = posted_form();
|
||||
form.row_display_columns = vec!["number".into(), "gone".into()];
|
||||
|
||||
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_draft_never_trusts_the_posted_table_list() {
|
||||
// `existing_profile_tables` is what duplicate-name checks read, so it
|
||||
// must come from the server, not the form.
|
||||
assert!(posted_form().to_draft().existing_profile_tables.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,154 @@ use askama::Template;
|
||||
|
||||
use crate::ui::{Alert, Nav, render};
|
||||
|
||||
use super::state::AddTablePageState;
|
||||
use super::{
|
||||
draft::{COLUMN_TYPES, CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
|
||||
state::AddTablePageState,
|
||||
};
|
||||
|
||||
/// GET /admin/tables/new
|
||||
/// GET /admin/tables/new — the page shell around the builder.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/add_table/add_table.html")]
|
||||
struct AddTablePage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a AddTablePageState,
|
||||
column_types: &'static [&'static str],
|
||||
temporal_types: &'static [&'static str],
|
||||
gtin_types: &'static [&'static str],
|
||||
currency_codes: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// POST /admin/tables/builder — the `#builder` swap, which is the same markup
|
||||
/// the page embeds, so one template serves both.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/add_table/builder.html")]
|
||||
struct BuilderFragment<'a> {
|
||||
page: &'a AddTablePageState,
|
||||
column_types: &'static [&'static str],
|
||||
temporal_types: &'static [&'static str],
|
||||
gtin_types: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &AddTablePageState) -> String {
|
||||
render(&AddTablePage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
column_types: COLUMN_TYPES,
|
||||
temporal_types: TEMPORAL_TYPES,
|
||||
gtin_types: GTIN_TYPES,
|
||||
currency_codes: CURRENCY_CODES,
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /admin/tables — the #submission-status swap.
|
||||
pub(crate) fn render_builder(page: &AddTablePageState) -> String {
|
||||
render(&BuilderFragment {
|
||||
page,
|
||||
column_types: COLUMN_TYPES,
|
||||
temporal_types: TEMPORAL_TYPES,
|
||||
gtin_types: GTIN_TYPES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Used when the page itself cannot be loaded (auth or backend failure).
|
||||
pub(crate) fn render_submission_error(message: &str) -> String {
|
||||
render(&Alert::error("Could not create the table", message))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::add_table::draft::{ColumnDefinition, LinkMode, MoneyMode, TableDraft};
|
||||
|
||||
fn page() -> AddTablePageState {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.profile_name = "billing".to_string();
|
||||
draft.table_name = "invoice".to_string();
|
||||
draft.columns.push(ColumnDefinition {
|
||||
name: "number".to_string(),
|
||||
data_type: "text".to_string(),
|
||||
indexed: true,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
});
|
||||
draft.set_available_relation_tables(vec!["customer".to_string()]);
|
||||
draft.cycle_link_mode(0);
|
||||
draft.toggle_row_display_candidate(1);
|
||||
|
||||
AddTablePageState {
|
||||
nav: Nav::default(),
|
||||
profiles: vec!["billing".to_string()],
|
||||
draft,
|
||||
status: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `render` turns a template failure into an error string rather than
|
||||
/// panicking, so the markup has to be asserted on.
|
||||
#[test]
|
||||
fn the_builder_carries_the_whole_draft_and_the_preview() {
|
||||
let html = render_builder(&page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
// Every part of the draft travels with the next request.
|
||||
assert!(html.contains(r#"name="column_names" value="number""#));
|
||||
assert!(html.contains(r#"name="column_indexed" value="yes""#));
|
||||
assert!(html.contains(r#"name="link_tables" value="customer""#));
|
||||
assert!(html.contains(r#"name="link_modes" value="optional""#));
|
||||
assert!(html.contains(r#"name="row_display_columns" value="number""#));
|
||||
// The preview shows the schema as it will exist.
|
||||
assert!(html.contains("customer_id"));
|
||||
assert!(html.contains("BIGSERIAL"));
|
||||
assert!(html.contains("TIMESTAMPTZ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_offers_the_new_profile_option_and_the_currency_list() {
|
||||
let html = render_page(&page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains(r#"value="__new__""#));
|
||||
assert!(html.contains(r#"<datalist id="currency-codes">"#));
|
||||
assert!(html.contains(r#"<option value="EUR">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_fields_follow_the_pending_column_type() {
|
||||
let mut state = page();
|
||||
assert!(!render_builder(&state).contains(r#"name="temporal_type_input""#));
|
||||
|
||||
state.draft.column_type_input = "temporal".to_string();
|
||||
let html = render_builder(&state);
|
||||
assert!(html.contains(r#"name="temporal_type_input""#));
|
||||
assert!(!html.contains(r#"name="gtin_type_input""#));
|
||||
|
||||
state.draft.column_type_input = "gtin".to_string();
|
||||
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
|
||||
|
||||
// Money reveals rounding, and the base currency becomes editable.
|
||||
state.draft.column_type_input = "money".to_string();
|
||||
let html = render_builder(&state);
|
||||
assert!(html.contains(r#"name="column_rounding_input""#));
|
||||
assert!(html.contains(r#"list="currency-codes""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_profile_fields_appear_only_for_a_new_profile() {
|
||||
let mut state = page();
|
||||
assert!(!render_builder(&state).contains(r#"name="profile_name_input" value"#));
|
||||
|
||||
state.draft.creating_new_profile = true;
|
||||
let html = render_builder(&state);
|
||||
assert!(html.contains(r#"name="profile_name_input""#));
|
||||
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
|
||||
let html = render_builder(&page());
|
||||
|
||||
assert!(html.contains(r#""action": "cycle-link", "index": "0""#));
|
||||
assert!(html.contains("mode-optional"));
|
||||
assert_eq!(LinkMode::Optional.label(), "optional");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,36 @@ table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||
th { position: sticky; top: 0; background: #f9fafb; }
|
||||
|
||||
/* ---------- Table builder (Add table) ---------- */
|
||||
|
||||
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
|
||||
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
|
||||
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
|
||||
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
|
||||
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
|
||||
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
|
||||
.builder-section > button.secondary:hover { background: #e9edf4; }
|
||||
|
||||
.builder-table { margin-top: 14px; }
|
||||
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
|
||||
.builder-table td { font-size: 13px; }
|
||||
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
|
||||
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
|
||||
.builder-table.preview .source-relation td { color: #3c5a86; }
|
||||
|
||||
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
|
||||
|
||||
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
|
||||
button.toggle:hover { background: #e9edf4; }
|
||||
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
|
||||
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
|
||||
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
|
||||
button.danger:hover { background: #f8e5e5; }
|
||||
|
||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
|
||||
.tag + .tag { margin-left: 4px; }
|
||||
|
||||
/* ---------- Narrow screens ---------- */
|
||||
|
||||
@media (max-width: 850px) {
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
{# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}Add table{% endblock %}
|
||||
{% block eyebrow %}Table definition{% endblock %}
|
||||
{% block heading %}Add table{% endblock %}
|
||||
{% block lead %}<p>Create a table through the existing gRPC table-definition service.</p>{% endblock %}
|
||||
{% block lead %}<p>Build the table one column at a time, the same way the terminal client does.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/tables" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
{#
|
||||
One form holds the whole draft. `#builder` is what every interaction swaps,
|
||||
so the form element itself — and therefore the submit target — survives.
|
||||
#}
|
||||
<form id="table-form" hx-post="/admin/tables" hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.profiles %}
|
||||
<option value="{{ profile }}" {% if page.form.profile_name == *profile %}selected{% endif %}>{{ profile }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Table name<input name="table_name" value="{{ page.form.table_name }}" required placeholder="invoices"></label>
|
||||
<label class="wide">Columns
|
||||
<textarea name="columns" rows="9" required
|
||||
placeholder="number: text:indexed issued_on: date stock: int:quantity-ledger">{{ page.form.columns }}</textarea>
|
||||
<small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small>
|
||||
</label>
|
||||
<label>Additional indexed columns<input name="indexed_columns" value="{{ page.form.indexed_columns }}" placeholder="number, issued_on"></label>
|
||||
<label>Base currency<input name="base_currency" value="{{ page.form.base_currency }}" maxlength="3" placeholder="EUR"></label>
|
||||
<label>Required links<input name="required_links" value="{{ page.form.required_links }}" placeholder="customer, address"></label>
|
||||
<label>Optional links<input name="optional_links" value="{{ page.form.optional_links }}" placeholder="project"></label>
|
||||
<label>Row display columns<input name="row_display_columns" value="{{ page.form.row_display_columns }}" placeholder="name, ico"></label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Create table</button>
|
||||
<div id="builder" aria-live="polite">
|
||||
{% include "pages/add_table/builder.html" %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<datalist id="currency-codes">
|
||||
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endblock %}
|
||||
|
||||
237
web/templates/pages/add_table/builder.html
Normal file
237
web/templates/pages/add_table/builder.html
Normal file
@@ -0,0 +1,237 @@
|
||||
{#
|
||||
POST /admin/tables/builder — crate::pages::add_table::ui::BuilderFragment.
|
||||
|
||||
The whole builder, which is also what `add_table.html` embeds on first load.
|
||||
Every control posts the entire draft back to /admin/tables/builder and swaps
|
||||
this markup in again, so the rules live on the server exactly as they do in
|
||||
the TUI client. The hidden inputs in the columns, relations and display
|
||||
sections are what carry the draft across requests.
|
||||
#}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%}
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Table</h2>
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" hx-post="/admin/tables/builder" hx-trigger="change"
|
||||
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "refresh"}'>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.profiles %}
|
||||
<option value="{{ profile }}" {% if page.selected_profile() == profile.as_str() %}selected{% endif %}>{{ profile }}</option>
|
||||
{% endfor %}
|
||||
<option value="__new__" {% if page.draft.creating_new_profile %}selected{% endif %}>+ New profile…</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{% if page.draft.show_profile_name_input() %}
|
||||
<label>New profile name
|
||||
<input name="profile_name_input" value="{{ page.draft.profile_name_input }}" placeholder="bookkeeping">
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.draft.show_accounting_currency() %}
|
||||
<label>Accounting currency
|
||||
<input name="accounting_currency" value="{{ page.draft.accounting_currency }}" list="currency-codes"
|
||||
maxlength="3" placeholder="EUR">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
|
||||
{% endif %}
|
||||
|
||||
<label>Table name
|
||||
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice"
|
||||
hx-post="/admin/tables/builder" hx-trigger="change" hx-include="#table-form"
|
||||
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "refresh"}'>
|
||||
</label>
|
||||
|
||||
{% if page.draft.show_base_currency() %}
|
||||
<label>Base currency
|
||||
<input name="base_currency" value="{{ page.draft.base_currency }}" list="currency-codes"
|
||||
maxlength="3" placeholder="EUR">
|
||||
<small>Required while the table has a MONEY or ACCOUNTING column.</small>
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="hidden" name="base_currency" value="{{ page.draft.base_currency }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Add a column</h2>
|
||||
<div class="form-grid">
|
||||
<label>Column name
|
||||
<input name="column_name_input" value="{{ page.draft.column_name_input }}" placeholder="number">
|
||||
</label>
|
||||
<label>Column type
|
||||
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
|
||||
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "refresh"}'>
|
||||
<option value="">Choose a type</option>
|
||||
{% for column_type in column_types %}
|
||||
<option value="{{ column_type }}" {% if page.draft.column_type_input == *column_type %}selected{% endif %}>{{ column_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{% if page.draft.show_temporal_type() %}
|
||||
<label>Temporal type
|
||||
<select name="temporal_type_input">
|
||||
<option value="">Choose a temporal type</option>
|
||||
{% for temporal_type in temporal_types %}
|
||||
<option value="{{ temporal_type }}" {% if page.draft.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.draft.show_gtin_type() %}
|
||||
<label>GTIN type
|
||||
<select name="gtin_type_input">
|
||||
<option value="">Choose a GTIN length</option>
|
||||
{% for gtin_type in gtin_types %}
|
||||
<option value="{{ gtin_type }}" {% if page.draft.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.draft.show_rounding() %}
|
||||
<label>Rounding
|
||||
<select name="column_rounding_input">
|
||||
<option value="none" {% if page.draft.column_rounding_input != "half-up" %}selected{% endif %}>none</option>
|
||||
<option value="half-up" {% if page.draft.column_rounding_input == "half-up" %}selected{% endif %}>half-up</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<label>Indexing
|
||||
<select name="column_indexing_input">
|
||||
<option value="no" {% if page.draft.column_indexing_input != "yes" %}selected{% endif %}>no</option>
|
||||
<option value="yes" {% if page.draft.column_indexing_input == "yes" %}selected{% endif %}>yes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Quantity ledger
|
||||
<select name="column_quantity_ledger_input">
|
||||
<option value="no" {% if page.draft.column_quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
|
||||
<option value="yes" {% if page.draft.column_quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
|
||||
</select>
|
||||
<small>INT, BIGINT, DECIMAL or MONEY only.</small>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>Add column</button>
|
||||
</section>
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Columns <span class="count">{{ page.draft.columns.len() }}</span></h2>
|
||||
{% if page.draft.columns.is_empty() %}
|
||||
<p class="hint">No columns yet. Describe one above and press <em>Add column</em>.</p>
|
||||
{% else %}
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Indexed</th><th>Options</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for column in page.draft.columns %}
|
||||
<tr>
|
||||
<td><code>{{ column.name }}</code></td>
|
||||
<td>{{ column.data_type }}</td>
|
||||
<td>
|
||||
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||
hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
||||
{% if !column.option_label().is_empty() %}<span class="tag">{{ column.option_label() }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||
hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# The draft itself: one set of fields per column, in order. #}
|
||||
{% for column in page.draft.columns %}
|
||||
<input type="hidden" name="column_names" value="{{ column.name }}">
|
||||
<input type="hidden" name="column_types" value="{{ column.data_type }}">
|
||||
<input type="hidden" name="column_indexed" value="{% if column.indexed %}yes{% else %}no{% endif %}">
|
||||
<input type="hidden" name="column_quantity_ledger" value="{% if column.quantity_ledger %}yes{% else %}no{% endif %}">
|
||||
<input type="hidden" name="column_rounding" value="{{ column.money_mode.label() }}">
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Relations</h2>
|
||||
{% if page.draft.links.is_empty() %}
|
||||
<p class="hint">No other tables in this profile to link to.</p>
|
||||
{% else %}
|
||||
<p class="hint">Each active relation adds a <code><table>_id</code> column.</p>
|
||||
<ul class="builder-list">
|
||||
{% for link in page.draft.links %}
|
||||
<li>
|
||||
<button type="button" class="toggle mode-{{ link.mode.label() }}" hx-post="/admin/tables/builder"
|
||||
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "cycle-link", "index": "{{ loop.index0 }}"}'>{{ link.mode.label() }}</button>
|
||||
<code>{{ link.linked_table_name }}</code>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% for link in page.draft.links %}
|
||||
<input type="hidden" name="link_tables" value="{{ link.linked_table_name }}">
|
||||
<input type="hidden" name="link_modes" value="{{ link.mode.label() }}">
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Row display columns</h2>
|
||||
<p class="hint">What identifies a row to users, in the order chosen. Pick <code>id</code> to clear the list.</p>
|
||||
<ul class="builder-list">
|
||||
{% for candidate in page.row_display_candidates() %}
|
||||
<li>
|
||||
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||
hx-target="#builder" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "toggle-display", "index": "{{ candidate.index }}"}'>{{ candidate.mark() }}</button>
|
||||
<code>{{ candidate.name }}</code>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% for display in page.draft.row_display_columns %}
|
||||
<input type="hidden" name="row_display_columns" value="{{ display }}">
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="builder-section">
|
||||
<h2>Table definition preview</h2>
|
||||
<table class="builder-table preview">
|
||||
<thead><tr><th></th><th>Column</th><th>Type</th><th>Options</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in page.draft.preview_rows() %}
|
||||
<tr class="source-{{ row.source }}">
|
||||
<td class="mark">{{ row.mark }}</td>
|
||||
<td><code>{{ row.column }}</code></td>
|
||||
<td>{{ row.data_type }}</td>
|
||||
<td>{{ row.option }}</td>
|
||||
<td>{{ row.source }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Create table</button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user