web interface for table_definition improved

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

View File

@@ -8,6 +8,7 @@ use axum::{
};
mod pages;
mod schema;
mod services;
mod ui;
mod analytics {
@@ -136,6 +137,7 @@ fn router(state: AppState) -> Router {
.merge(pages::analytics::router())
.merge(pages::login::router())
.merge(pages::admin::admin::router())
.merge(pages::admin::table_definition::router())
.merge(pages::add_table::router())
.merge(pages::add_logic::router())
.merge(pages::add_validation::router())
@@ -231,6 +233,48 @@ mod tests {
assert!(!body.contains("hx-post=\"/logout\""));
}
/// Every table-definition endpoint is mounted, and every one of them is
/// behind a session: without a cookie there is no request to sign, so each
/// answers with the redirect to the login page rather than a 404 or a call
/// to the backend.
#[tokio::test]
async fn the_table_definition_workspace_is_mounted_and_needs_a_session() {
for path in ["/admin/table-definition", "/admin/table-definition/workspace"] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
for path in [
"/admin/table-definition/columns",
"/admin/table-definition/columns/builder",
"/admin/table-definition/rename",
"/admin/table-definition/delete",
"/admin/table-definition/copy",
"/admin/table-definition/invoice-template",
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("profile=billing&table=invoice"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
}
#[tokio::test]
async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await;

View File

@@ -3,103 +3,21 @@
//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics
//! with the terminal-specific parts removed (`ratatui` cursors, the canvas
//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below —
//! the type catalogue, canonicalisation, field visibility and validation —
//! matches the client so the two frontends accept and reject exactly the same
//! table definitions.
//! canonicalisation, field visibility and validation — matches the client so
//! the two frontends accept and reject exactly the same table definitions.
//!
//! Keeping it dependency-light is deliberate: this module is the candidate for
//! extraction into a crate shared by `client`, `web` and `server`. Everything
//! above [`TableDraft::into_request`] is already proto-free; that one method is
//! the seam where a shared crate would hand back a plain draft for each
//! frontend to map to its own generated request type.
//! What a *column* may be is not here: that is [`crate::schema`], which the
//! append screen in `admin/table_definition` shares. This module is only what
//! is true of a table being created — its profile, its name, its links, and
//! what identifies one of its rows.
use crate::definitions::table_definition::{
ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest,
TableLink as ProtoTableLink,
use crate::{
definitions::table_definition::{
PostTableDefinitionRequest, TableLink as ProtoTableLink,
},
schema::{ColumnDraft, proto_columns, validate_identifier},
};
/// Column types offered in the type picker. `temporal` and `gtin` are pickers
/// of their own: neither is a storable type, each resolves to a subtype below.
pub(crate) const COLUMN_TYPES: &[&str] = &[
"text",
"boolean",
"money",
"accounting",
"int",
"temporal",
"phone",
"iban",
"email_address",
"credit_card",
"gtin",
"bigint",
];
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
/// Every type the server accepts, i.e. what a canonicalised column may be.
const CANONICAL_TYPES: &[&str] = &[
"text",
"boolean",
"date",
"time",
"instant",
"raw_datetime",
"phone",
"iban",
"email_address",
"credit_card",
"gtin_8",
"gtin_12",
"gtin_13",
"gtin_14",
"money",
"accounting",
"int",
"bigint",
];
/// Whether a column of this type declares a currency.
///
/// Both MONEY and ACCOUNTING do, which is why this is a named predicate rather
/// than an inline comparison: written by hand, the ACCOUNTING half is easy to
/// forget, and forgetting it is silent — the currency is still stored and sent,
/// it just stops being validated or displayed.
pub(crate) fn carries_currency(field_type: &str) -> bool {
field_type.eq_ignore_ascii_case("money") || field_type.eq_ignore_ascii_case("accounting")
}
/// Types a quantity-ledger column may use.
fn quantity_ledger_type_allowed(field_type: &str) -> bool {
matches!(field_type, "int" | "bigint" | "money")
|| (field_type.starts_with("decimal(") && field_type.ends_with(')'))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum MoneyMode {
#[default]
Exact,
Rounded,
}
impl MoneyMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::Rounded => "half-up",
}
}
fn from_input(value: &str) -> Self {
if value.trim().eq_ignore_ascii_case("half-up") {
Self::Rounded
} else {
Self::Exact
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum LinkMode {
#[default]
@@ -143,29 +61,6 @@ impl LinkMode {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ColumnDefinition {
pub name: String,
pub data_type: String,
pub indexed: bool,
pub quantity_ledger: bool,
pub money_mode: MoneyMode,
pub currency: String,
}
impl ColumnDefinition {
/// The `option` cell of the preview, mirroring the client's preview table.
pub(crate) fn option_label(&self) -> String {
let has_currency = carries_currency(&self.data_type);
match (self.indexed, has_currency) {
(true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()),
(true, false) => "indexed".to_string(),
(false, true) => format!("{}, {}", self.currency, self.money_mode.label()),
(false, false) => String::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkDefinition {
pub linked_table_name: String,
@@ -193,17 +88,9 @@ pub(crate) struct TableDraft {
pub table_name: String,
// The column-input panel: one pending column being described.
pub column_name_input: String,
pub column_type_input: String,
pub temporal_type_input: String,
pub gtin_type_input: String,
pub column_indexing_input: String,
pub column_quantity_ledger_input: String,
pub column_rounding_input: String,
pub column_currency_input: String,
/// The column panel: the pending column and the ones already described.
pub columns: ColumnDraft,
pub columns: Vec<ColumnDefinition>,
pub links: Vec<LinkDefinition>,
/// Columns identifying a row to users, in the order they are shown.
/// Empty means rows are identified by their id alone.
@@ -219,51 +106,11 @@ impl TableDraft {
pub(crate) fn new() -> Self {
Self {
accounting_currency: "EUR".to_string(),
column_indexing_input: "no".to_string(),
column_quantity_ledger_input: "no".to_string(),
column_rounding_input: "none".to_string(),
column_currency_input: "EUR".to_string(),
columns: ColumnDraft::new(),
..Self::default()
}
}
// ---- pending-column input -------------------------------------------
pub(crate) fn pending_column_carries_currency(&self) -> bool {
carries_currency(self.column_type_input.trim())
}
pub(crate) fn is_temporal_column_input(&self) -> bool {
self.column_type_input.trim().eq_ignore_ascii_case("temporal")
}
pub(crate) fn is_gtin_column_input(&self) -> bool {
self.column_type_input.trim().eq_ignore_ascii_case("gtin")
}
/// The storable type the pending inputs describe, resolving the `temporal`
/// and `gtin` pickers to their subtype. `None` while the choice is still
/// incomplete.
fn canonical_column_type_input(&self) -> Option<String> {
let column_type = self.column_type_input.trim().to_ascii_lowercase();
match column_type.as_str() {
"temporal" => {
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
TEMPORAL_TYPES
.contains(&temporal_type.as_str())
.then_some(temporal_type)
}
"gtin" => {
let gtin_type = self.gtin_type_input.trim();
GTIN_TYPES
.contains(&gtin_type)
.then(|| format!("gtin_{gtin_type}"))
}
"" => None,
_ => Some(column_type),
}
}
// ---- field visibility (the same rules the TUI canvas applies) --------
pub(crate) fn show_profile_name_input(&self) -> bool {
@@ -274,111 +121,16 @@ impl TableDraft {
self.creating_new_profile
}
pub(crate) fn show_temporal_type(&self) -> bool {
self.is_temporal_column_input()
}
pub(crate) fn show_gtin_type(&self) -> bool {
self.is_gtin_column_input()
}
/// Currency and rounding both apply only to a money column.
pub(crate) fn show_money_options(&self) -> bool {
self.pending_column_carries_currency()
}
// ---- mutations -------------------------------------------------------
/// Appends the pending column, then clears the input panel.
pub(crate) fn add_column_from_inputs(&mut self) -> Result<String, String> {
let Some(column_type) = self.canonical_column_type_input() else {
return Err("Both a column name and a column type are required.".to_string());
};
// An accounting column is always named `accounting`.
let column_name = if column_type.eq_ignore_ascii_case("accounting") {
"accounting".to_string()
} else {
self.column_name_input.trim().to_string()
};
if column_name.is_empty() {
return Err("Both a column name and a column type are required.".to_string());
}
if let Some(error) = validate_identifier(&column_name, "Column name", true) {
return Err(error);
}
if let Some(error) = validate_field_type(&column_type) {
return Err(error);
}
if self.columns.iter().any(|column| column.name == column_name) {
return Err(format!("A column named `{column_name}` already exists."));
}
let quantity_ledger = self
.column_quantity_ledger_input
.trim()
.eq_ignore_ascii_case("yes");
if quantity_ledger && !quantity_ledger_type_allowed(&column_type) {
return Err(
"Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(),
);
}
let has_currency = carries_currency(&column_type);
let currency = if has_currency {
normalize_currency_input(&self.column_currency_input)?
} else {
String::new()
};
self.columns.push(ColumnDefinition {
name: column_name.clone(),
data_type: column_type,
indexed: self
.column_indexing_input
.trim()
.eq_ignore_ascii_case("yes"),
quantity_ledger,
money_mode: if has_currency {
MoneyMode::from_input(&self.column_rounding_input)
} else {
MoneyMode::Exact
},
currency,
});
self.clear_column_inputs();
Ok(format!("Column `{column_name}` added."))
}
fn clear_column_inputs(&mut self) {
self.column_name_input.clear();
self.column_type_input.clear();
self.temporal_type_input.clear();
self.gtin_type_input.clear();
self.column_indexing_input = "no".to_string();
self.column_quantity_ledger_input = "no".to_string();
self.column_rounding_input = "none".to_string();
self.column_currency_input = "EUR".to_string();
}
/// Removes one column, and drops it from the display columns with it.
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
if index >= self.columns.len() {
return Err("That column no longer exists.".to_string());
}
let removed = self.columns.remove(index);
let removed = self.columns.remove(index)?;
self.row_display_columns
.retain(|display| display != &removed.name);
Ok(format!("Column `{}` removed.", removed.name))
}
pub(crate) fn toggle_column_indexed(&mut self, index: usize) {
if let Some(column) = self.columns.get_mut(index) {
column.indexed = !column.indexed;
}
}
pub(crate) fn cycle_link_mode(&mut self, index: usize) {
if let Some(link) = self.links.get_mut(index) {
link.mode = link.mode.next();
@@ -396,7 +148,12 @@ impl TableDraft {
self.row_display_columns.clear();
return;
}
let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else {
let Some(column) = self
.columns
.added
.get(index - 1)
.map(|column| column.name.clone())
else {
return;
};
match self
@@ -453,14 +210,6 @@ impl TableDraft {
.any(|name| name == &self.table_name)
}
pub(crate) fn selected_index_names(&self) -> Vec<String> {
self.columns
.iter()
.filter(|column| column.indexed)
.map(|column| column.name.clone())
.collect()
}
/// Position of `column` among the display columns, counting from 1.
pub(crate) fn row_display_position(&self, column: &str) -> Option<usize> {
self.row_display_columns
@@ -507,7 +256,7 @@ impl TableDraft {
});
}
for column in &self.columns {
for column in &self.columns.added {
rows.push(PreviewRow {
mark: self
.row_display_position(&column.name)
@@ -556,29 +305,7 @@ impl TableDraft {
if self.columns.is_empty() {
return Err("Add at least one column before saving.".to_string());
}
for column in &self.columns {
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
return Err(error);
}
if let Some(error) = validate_field_type(&column.data_type) {
return Err(format!("Column `{}`: {error}", column.name));
}
// The same rule the server enforces: required for a money column,
// forbidden for every other type. `add_column_from_inputs` already
// applies it, but a draft rebuilt from a posted form has not been
// through that path.
if carries_currency(&column.data_type) {
if let Err(error) = normalize_currency_input(&column.currency) {
return Err(format!("Column `{}`: {error}", column.name));
}
} else if !column.currency.trim().is_empty() {
return Err(format!(
"Column `{}`: only MONEY and ACCOUNTING columns may declare a currency.",
column.name
));
}
}
Ok(())
self.columns.validate()
}
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
@@ -588,21 +315,8 @@ impl TableDraft {
Ok(PostTableDefinitionRequest {
table_name: self.table_name.clone(),
profile_name: self.effective_profile_name(),
columns: self
.columns
.iter()
.map(|column| ProtoColumnDefinition {
name: column.name.clone(),
field_type: column.data_type.clone(),
rounding: match column.money_mode {
MoneyMode::Rounded => MoneyRounding::HalfUp.into(),
MoneyMode::Exact => MoneyRounding::None.into(),
},
quantity_ledger: column.quantity_ledger,
currency: column.currency.clone(),
})
.collect(),
indexes: self.selected_index_names(),
columns: proto_columns(&self.columns.added),
indexes: self.columns.selected_index_names(),
links: self
.links
.iter()
@@ -622,14 +336,6 @@ impl TableDraft {
}
}
fn normalize_currency_input(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Err("Currency must be a three-letter ISO-4217 code".to_string());
}
Ok(currency)
}
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
if !draft.creating_new_profile {
return None;
@@ -641,87 +347,16 @@ pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String>
None
}
/// PostgreSQL identifier rules, plus the names this schema reserves.
pub(crate) fn validate_identifier(
value: &str,
label: &str,
reject_table_reserved: bool,
) -> Option<String> {
if value.is_empty() {
return Some(format!("{label} cannot be empty."));
}
if value != value.trim() {
return Some(format!("{label} cannot start or end with a space."));
}
if value.starts_with('_') {
return Some(format!("{label} cannot start with an underscore."));
}
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(format!("{label} cannot start with a number."));
}
if value.len() > 63 {
return Some(format!("{label} cannot be longer than 63 characters."));
}
if value
.chars()
.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_')
{
return Some(format!(
"{label} may only use lowercase letters, digits and underscores."
));
}
if reject_table_reserved
&& (value == "id"
|| value == "deleted"
|| value == "created_at"
|| value == "row_revision"
|| value.ends_with("_id"))
{
return Some(format!("{label} uses a reserved name."));
}
if !reject_table_reserved
&& (value == "public" || value == "information_schema" || value.starts_with("pg_"))
{
return Some("That profile name is reserved by PostgreSQL.".to_string());
}
None
}
pub(crate) fn validate_field_type(field_type: &str) -> Option<String> {
let field_type = field_type.to_lowercase();
if CANONICAL_TYPES.contains(&field_type.as_str()) {
return None;
}
Some(format!("`{field_type}` is not a valid field type."))
}
/// ISO-4217 codes offered as currency suggestions, matching the client's list.
pub(crate) const CURRENCY_CODES: &[&str] = &[
"EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM",
"BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN",
"BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK",
"DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF",
"GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD",
"JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP",
"LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR",
"MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB",
"PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD",
"SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW",
"UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD",
"XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK",
"ZWL",
];
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{ColumnDefinition, MoneyMode};
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
let mut draft = TableDraft::new();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.push(ColumnDefinition {
draft.columns.added.push(ColumnDefinition {
name: name.to_string(),
data_type: data_type.to_string(),
indexed: false,
@@ -736,89 +371,6 @@ mod tests {
draft
}
#[test]
fn temporal_and_gtin_pickers_resolve_to_canonical_types() {
let mut draft = TableDraft::new();
draft.column_name_input = "occurred_at".to_string();
draft.column_type_input = "temporal".to_string();
// Incomplete while no subtype is chosen.
assert!(draft.canonical_column_type_input().is_none());
assert!(draft.show_temporal_type());
draft.temporal_type_input = "raw_datetime".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].data_type, "raw_datetime");
// Inputs are cleared for the next column.
assert!(draft.temporal_type_input.is_empty());
draft.column_name_input = "barcode".to_string();
draft.column_type_input = "gtin".to_string();
draft.gtin_type_input = "13".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[1].data_type, "gtin_13");
}
#[test]
fn invalid_identifiers_and_types_are_refused_at_add_time() {
let mut draft = TableDraft::new();
draft.column_type_input = "text".to_string();
draft.column_name_input = "Total".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "customer_id".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "created_at".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "total".to_string();
draft.column_type_input = "timestamptz".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_type_input = "text".to_string();
assert!(draft.add_column_from_inputs().is_ok());
// Duplicates are refused too.
draft.column_name_input = "total".to_string();
draft.column_type_input = "text".to_string();
assert!(draft.add_column_from_inputs().is_err());
}
#[test]
fn quantity_ledger_requires_a_numeric_type() {
let mut draft = TableDraft::new();
draft.column_name_input = "note".to_string();
draft.column_type_input = "text".to_string();
draft.column_quantity_ledger_input = "yes".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_type_input = "int".to_string();
assert!(draft.add_column_from_inputs().is_ok());
assert!(draft.columns[0].quantity_ledger);
}
#[test]
fn accounting_column_is_always_named_accounting() {
let mut draft = TableDraft::new();
draft.column_name_input = "whatever".to_string();
draft.column_type_input = "accounting".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].name, "accounting");
}
#[test]
fn money_columns_require_a_valid_currency() {
let mut draft = TableDraft::new();
draft.column_name_input = "total".to_string();
draft.column_type_input = "money".to_string();
draft.column_currency_input = "EU".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_currency_input = "eur".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].currency, "EUR");
}
#[test]
fn new_profile_accounting_currency_must_exist_in_the_iso_registry() {
let mut draft = draft_with_column("total", "int");
@@ -833,40 +385,6 @@ mod tests {
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
}
/// `add_column_from_inputs` enforces this, but a draft rebuilt from a
/// posted form skips that path, so `validate` has to enforce it too.
#[test]
fn a_rebuilt_draft_is_still_held_to_the_currency_rule() {
let mut draft = draft_with_column("total", "money");
draft.columns[0].currency = String::new();
assert!(draft.validate().is_err());
draft.columns[0].currency = "XYZ".to_string();
assert!(draft.validate().is_err());
draft.columns[0].currency = "EUR".to_string();
assert!(draft.validate().is_ok());
// Forbidden on everything else, exactly as the server has it.
let mut draft = draft_with_column("note", "text");
draft.columns[0].currency = "EUR".to_string();
assert!(draft.validate().is_err());
}
#[test]
fn an_accounting_column_shows_its_currency_too() {
let mut draft = TableDraft::new();
draft.column_type_input = "accounting".to_string();
draft.column_currency_input = "czk".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].currency, "CZK");
assert_eq!(draft.columns[0].option_label(), "CZK, exact");
draft.toggle_column_indexed(0);
assert_eq!(draft.columns[0].option_label(), "indexed, CZK, exact");
}
#[test]
fn existing_profile_sends_no_accounting_currency() {
let draft = draft_with_column("total", "int");
@@ -892,6 +410,14 @@ mod tests {
assert!(draft.validate().is_err());
}
#[test]
fn a_table_with_no_columns_is_refused() {
let mut draft = draft_with_column("total", "int");
draft.remove_column(0).unwrap();
assert!(draft.validate().is_err());
}
#[test]
fn links_keep_their_mode_when_the_table_list_is_reloaded() {
let mut draft = draft_with_column("total", "int");
@@ -926,7 +452,7 @@ mod tests {
#[test]
fn row_display_columns_toggle_in_the_order_they_were_chosen() {
let mut draft = draft_with_column("number", "text");
draft.columns.push(ColumnDefinition {
draft.columns.added.push(ColumnDefinition {
name: "issued_on".to_string(),
data_type: "date".to_string(),
indexed: false,
@@ -979,9 +505,8 @@ mod tests {
#[test]
fn indexed_columns_become_the_index_list() {
let mut draft = draft_with_column("number", "text");
draft.toggle_column_indexed(0);
draft.columns.toggle_indexed(0);
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
}
}

View File

@@ -5,13 +5,16 @@
//! the TUI client applies in-process between keystrokes.
use axum::{
extract::State,
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
use crate::{AppState, services::authenticated_request};
use crate::{
AppState,
services::{authenticated_request, reject_cross_site},
};
use super::{
draft::TableDraft,
@@ -20,9 +23,24 @@ use super::{
ui,
};
/// The profile the table-definition workspace hands over when it sends the
/// user here to create a table, so the picker opens on the right one.
#[derive(Debug, Default, serde::Deserialize)]
pub(crate) struct NewTableQuery {
#[serde(default)]
profile: String,
}
/// GET /admin/tables/new
pub(crate) async fn new_table_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
match load_page(state, &headers, TableDraft::new(), None, None).await {
pub(crate) async fn new_table_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<NewTableQuery>,
) -> Response {
let mut draft = TableDraft::new();
draft.profile_name = query.profile.trim().to_string();
match load_page(state, &headers, draft, None, None).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error_response(error),
}
@@ -115,7 +133,7 @@ pub(crate) async fn create_table(
fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
let index = form.index.unwrap_or(0);
match form.action.as_str() {
"add-column" => match page.draft.add_column_from_inputs() {
"add-column" => match page.draft.columns.add_from_inputs() {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
@@ -123,20 +141,13 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
"toggle-index" => page.draft.toggle_column_indexed(index),
"toggle-index" => page.draft.columns.toggle_indexed(index),
"cycle-link" => page.draft.cycle_link_mode(index),
"toggle-display" => page.draft.toggle_row_display_candidate(index),
_ => {}
}
}
fn reject_cross_site(headers: &HeaderMap) -> Option<Response> {
headers
.get("sec-fetch-site")
.is_some_and(|value| value == "cross-site")
.then(|| (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response())
}
fn load_error_response(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),

View File

@@ -5,8 +5,15 @@
//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`)
//! decodes the repeats into `Vec`s, which `to_draft` zips back into a
//! [`TableDraft`].
//!
//! The column half of that form is the shared one — the field names here are
//! the same ones [`crate::schema::ColumnForm`] declares, and both are rebuilt
//! by [`crate::schema::columns_from_rows`], so the two screens that describe
//! columns cannot drift apart.
use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft};
use crate::schema::{ColumnDraft, columns_from_rows};
use super::draft::{LinkDefinition, LinkMode, TableDraft};
/// The `profile_name` option meaning "create a new profile too".
pub(crate) const NEW_PROFILE: &str = "__new__";
@@ -39,6 +46,10 @@ pub(crate) struct BuilderForm {
#[serde(default)]
pub gtin_type_input: String,
#[serde(default)]
pub decimal_precision_input: String,
#[serde(default)]
pub decimal_scale_input: String,
#[serde(default)]
pub column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
@@ -71,49 +82,37 @@ pub(crate) struct BuilderForm {
pub row_display_columns: Vec<String>,
}
fn is_yes(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("yes")
}
impl BuilderForm {
pub(crate) fn creating_new_profile(&self) -> bool {
self.profile_name == NEW_PROFILE
}
/// Rebuilds the draft this form was rendered from.
///
/// The column vectors are parallel, so a short one (a truncated or
/// tampered-with post) simply limits how many columns are reconstructed
/// rather than mis-pairing them.
pub(crate) fn to_draft(&self) -> TableDraft {
let creating_new_profile = self.creating_new_profile();
let column_count = [
self.column_names.len(),
self.column_types.len(),
self.column_indexed.len(),
self.column_quantity_ledger.len(),
self.column_rounding.len(),
self.column_currencies.len(),
]
.into_iter()
.min()
.unwrap_or(0);
let columns = (0..column_count)
.map(|index| ColumnDefinition {
name: self.column_names[index].clone(),
data_type: self.column_types[index].clone(),
indexed: is_yes(&self.column_indexed[index]),
quantity_ledger: is_yes(&self.column_quantity_ledger[index]),
money_mode: if self.column_rounding[index].trim() == MoneyMode::Rounded.label() {
MoneyMode::Rounded
} else {
MoneyMode::Exact
},
currency: self.column_currencies[index].clone(),
})
.collect::<Vec<_>>();
let columns = ColumnDraft {
name_input: self.column_name_input.clone(),
type_input: self.column_type_input.clone(),
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
decimal_precision_input: self.decimal_precision_input.clone(),
decimal_scale_input: self.decimal_scale_input.clone(),
indexing_input: self.column_indexing_input.clone(),
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
rounding_input: self.column_rounding_input.clone(),
currency_input: self.column_currency_input.clone(),
added: columns_from_rows(
&self.column_names,
&self.column_types,
&self.column_indexed,
&self.column_quantity_ledger,
&self.column_rounding,
&self.column_currencies,
),
// The table is being created here, so ACCOUNTING is on the table.
accounting_allowed: true,
};
let link_count = self.link_tables.len().min(self.link_modes.len());
let links = (0..link_count)
@@ -128,7 +127,7 @@ impl BuilderForm {
let row_display_columns = self
.row_display_columns
.iter()
.filter(|display| columns.iter().any(|column| &&column.name == display))
.filter(|display| columns.added.iter().any(|column| &&column.name == display))
.cloned()
.collect();
@@ -142,14 +141,6 @@ impl BuilderForm {
creating_new_profile,
accounting_currency: self.accounting_currency.clone(),
table_name: self.table_name.clone(),
column_name_input: self.column_name_input.clone(),
column_type_input: self.column_type_input.clone(),
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
column_indexing_input: self.column_indexing_input.clone(),
column_quantity_ledger_input: self.column_quantity_ledger_input.clone(),
column_rounding_input: self.column_rounding_input.clone(),
column_currency_input: self.column_currency_input.clone(),
columns,
links,
row_display_columns,
@@ -192,13 +183,18 @@ impl AddTablePageState {
None
},
}];
candidates.extend(self.draft.columns.iter().enumerate().map(|(index, column)| {
RowDisplayCandidate {
index: index + 1,
name: column.name.clone(),
position: self.draft.row_display_position(&column.name),
}
}));
candidates.extend(
self.draft
.columns
.added
.iter()
.enumerate()
.map(|(index, column)| RowDisplayCandidate {
index: index + 1,
name: column.name.clone(),
position: self.draft.row_display_position(&column.name),
}),
);
candidates
}
}
@@ -225,6 +221,7 @@ impl RowDisplayCandidate {
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::MoneyMode;
fn posted_form() -> BuilderForm {
BuilderForm {
@@ -247,9 +244,9 @@ mod tests {
fn round_trips_columns_links_and_display_columns() {
let draft = posted_form().to_draft();
assert_eq!(draft.columns.len(), 2);
assert!(draft.columns[0].indexed);
assert_eq!(draft.columns[1].money_mode, MoneyMode::Rounded);
assert_eq!(draft.columns.added.len(), 2);
assert!(draft.columns.added[0].indexed);
assert_eq!(draft.columns.added[1].money_mode, MoneyMode::Rounded);
assert_eq!(draft.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[1].mode, LinkMode::None);
assert_eq!(draft.row_display_columns, vec!["number"]);
@@ -276,9 +273,9 @@ mod tests {
let draft = form.to_draft();
assert_eq!(draft.columns.len(), 1);
assert_eq!(draft.columns[0].name, "number");
assert_eq!(draft.columns[0].data_type, "text");
assert_eq!(draft.columns.added.len(), 1);
assert_eq!(draft.columns.added[0].name, "number");
assert_eq!(draft.columns.added[0].data_type, "text");
}
#[test]

View File

@@ -1,12 +1,12 @@
use askama::Template;
use crate::ui::{Alert, Nav, render};
use super::{
draft::{COLUMN_TYPES, CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
state::AddTablePageState,
use crate::{
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
ui::{Alert, Nav, render},
};
use super::state::AddTablePageState;
/// GET /admin/tables/new — the page shell around the builder.
#[derive(Template)]
#[template(path = "pages/add_table/add_table.html")]
@@ -34,7 +34,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String {
render(&AddTablePage {
nav: page.nav.clone(),
page,
column_types: COLUMN_TYPES,
column_types: page.draft.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
currency_codes: CURRENCY_CODES,
@@ -44,7 +44,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String {
pub(crate) fn render_builder(page: &AddTablePageState) -> String {
render(&BuilderFragment {
page,
column_types: COLUMN_TYPES,
column_types: page.draft.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
})
@@ -60,13 +60,16 @@ pub(crate) fn render_submission_error(message: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::add_table::draft::{ColumnDefinition, LinkMode, MoneyMode, TableDraft};
use crate::{
pages::add_table::draft::{LinkMode, TableDraft},
schema::{ColumnDefinition, MoneyMode},
};
fn page() -> AddTablePageState {
let mut draft = TableDraft::new();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.push(ColumnDefinition {
draft.columns.added.push(ColumnDefinition {
name: "number".to_string(),
data_type: "text".to_string(),
indexed: true,
@@ -116,21 +119,41 @@ mod tests {
assert!(html.contains(r#"<option value="EUR">"#));
}
/// Every type the server accepts has to be reachable from the picker, or
/// the web UI silently offers less than the backend does.
#[test]
fn the_type_picker_offers_the_parameterised_and_interval_types() {
let html = render_builder(&page());
for column_type in ["decimal", "duration", "period", "accounting"] {
assert!(
html.contains(&format!(r#"<option value="{column_type}""#)),
"the type picker is missing {column_type}"
);
}
}
#[test]
fn conditional_fields_follow_the_pending_column_type() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="temporal_type_input""#));
state.draft.column_type_input = "temporal".to_string();
state.draft.columns.type_input = "temporal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="temporal_type_input""#));
assert!(!html.contains(r#"name="gtin_type_input""#));
state.draft.column_type_input = "gtin".to_string();
state.draft.columns.type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Decimal reveals its precision and scale.
state.draft.columns.type_input = "decimal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="decimal_precision_input""#));
assert!(html.contains(r#"name="decimal_scale_input""#));
// Money reveals its currency and rounding inputs.
state.draft.column_type_input = "money".to_string();
state.draft.columns.type_input = "money".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="column_rounding_input""#));
assert!(html.contains(r#"list="currency-codes""#));

View File

@@ -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",

View File

@@ -1 +1,2 @@
pub(crate) mod admin;
pub(crate) mod table_definition;

View 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,
})
}

View 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(),
}
}

View 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),
)
}

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

View 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: `&amp;` is what a browser reads
// back as the `&` separating the two parameters.
// Escaped, because it is an attribute: `&#38;` is what a browser reads
// back as the `&` separating the two parameters.
assert!(html.contains("?profile=billing&#38;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."));
}
}

895
web/src/schema/mod.rs Normal file
View File

@@ -0,0 +1,895 @@
//! The column vocabulary every table-definition screen shares.
//!
//! Two pages describe columns: `add_table` creates a table out of them, and
//! `admin/table_definition` appends them to a table that already exists. The
//! rules are the same in both — what types exist, what a name may be, when a
//! currency is required — so they live here rather than in either page, and
//! neither page is allowed its own copy.
//!
//! Everything above [`proto_columns`] is proto-free. This module is the piece
//! of the web UI that would move into a crate shared with `client` and
//! `server`; keeping the generated types out of the rules is what makes that
//! move a rename rather than a rewrite.
use crate::definitions::table_definition::{
ColumnDefinition as ProtoColumnDefinition, MoneyRounding,
};
/// Column types offered when a table is created. `temporal`, `gtin` and
/// `decimal` are pickers of their own: none is a storable type, each resolves
/// to a canonical type below once its follow-up fields are filled in.
pub(crate) const COLUMN_TYPES: &[&str] = &[
"text",
"boolean",
"money",
"accounting",
"int",
"bigint",
"decimal",
"temporal",
"duration",
"period",
"phone",
"iban",
"email_address",
"credit_card",
"gtin",
];
/// The same list without `accounting`, which the server only accepts while the
/// table is being created — an accounting column brings schema-managed
/// companions with it, so it cannot be bolted on afterwards.
pub(crate) const APPENDABLE_COLUMN_TYPES: &[&str] = &[
"text",
"boolean",
"money",
"int",
"bigint",
"decimal",
"temporal",
"duration",
"period",
"phone",
"iban",
"email_address",
"credit_card",
"gtin",
];
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
/// Every fixed type the server accepts. `decimal(p,s)` is not here because it
/// is parameterised; [`validate_field_type`] checks it separately.
const CANONICAL_TYPES: &[&str] = &[
"text",
"boolean",
"date",
"time",
"instant",
"raw_datetime",
"duration",
"period",
"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",
}
}
pub(crate) fn from_input(value: &str) -> Self {
if value.trim().eq_ignore_ascii_case("half-up") {
Self::Rounded
} else {
Self::Exact
}
}
}
#[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(),
}
}
}
/// The column-input panel and the columns it has produced so far.
///
/// One pending column is described in the inputs; pressing "add" validates it
/// and moves it into `added`. Both screens that describe columns embed one of
/// these, which is what keeps their rules identical.
#[derive(Clone, Debug, Default)]
pub(crate) struct ColumnDraft {
pub name_input: String,
pub type_input: String,
pub temporal_type_input: String,
pub gtin_type_input: String,
pub decimal_precision_input: String,
pub decimal_scale_input: String,
pub indexing_input: String,
pub quantity_ledger_input: String,
pub rounding_input: String,
pub currency_input: String,
pub added: Vec<ColumnDefinition>,
/// False on the append screen: an ACCOUNTING column can only be chosen
/// while the table is being created.
pub accounting_allowed: bool,
}
impl ColumnDraft {
/// A panel for a table that is being created, where every type applies.
pub(crate) fn new() -> Self {
Self {
accounting_allowed: true,
..Self::empty()
}
}
/// A panel for appending to an existing table.
pub(crate) fn for_append() -> Self {
Self {
accounting_allowed: false,
..Self::empty()
}
}
fn empty() -> Self {
Self {
indexing_input: "no".to_string(),
quantity_ledger_input: "no".to_string(),
rounding_input: "none".to_string(),
currency_input: "EUR".to_string(),
..Self::default()
}
}
/// The types this panel offers, which is the only place the accounting
/// rule shows up in the markup.
pub(crate) fn offered_types(&self) -> &'static [&'static str] {
if self.accounting_allowed {
COLUMN_TYPES
} else {
APPENDABLE_COLUMN_TYPES
}
}
// ---- field visibility (the same rules the TUI canvas applies) --------
pub(crate) fn pending_carries_currency(&self) -> bool {
carries_currency(self.type_input.trim())
}
pub(crate) fn show_temporal_type(&self) -> bool {
self.type_input.trim().eq_ignore_ascii_case("temporal")
}
pub(crate) fn show_gtin_type(&self) -> bool {
self.type_input.trim().eq_ignore_ascii_case("gtin")
}
pub(crate) fn show_decimal_arguments(&self) -> bool {
self.type_input.trim().eq_ignore_ascii_case("decimal")
}
/// Currency and rounding both apply only to a money column.
pub(crate) fn show_money_options(&self) -> bool {
self.pending_carries_currency()
}
// ---- the pending column ---------------------------------------------
/// The storable type the pending inputs describe, resolving the `temporal`,
/// `gtin` and `decimal` pickers to their canonical form. `None` while the
/// choice is still incomplete, `Err` when the follow-up fields are filled
/// in but wrong.
fn canonical_type_input(&self) -> Result<Option<String>, String> {
let column_type = self.type_input.trim().to_ascii_lowercase();
match column_type.as_str() {
"temporal" => {
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
Ok(TEMPORAL_TYPES
.contains(&temporal_type.as_str())
.then_some(temporal_type))
}
"gtin" => {
let gtin_type = self.gtin_type_input.trim();
Ok(GTIN_TYPES
.contains(&gtin_type)
.then(|| format!("gtin_{gtin_type}")))
}
"decimal" => {
let precision = self.decimal_precision_input.trim();
let scale = self.decimal_scale_input.trim();
if precision.is_empty() && scale.is_empty() {
return Ok(None);
}
validate_decimal_arguments(precision, scale)?;
Ok(Some(format!("decimal({precision},{scale})")))
}
"" => Ok(None),
_ => Ok(Some(column_type)),
}
}
/// Appends the pending column, then clears the input panel.
pub(crate) fn add_from_inputs(&mut self) -> Result<String, String> {
let Some(column_type) = self.canonical_type_input()? else {
return Err("Both a column name and a column type are required.".to_string());
};
if column_type.eq_ignore_ascii_case("accounting") && !self.accounting_allowed {
return Err(
"An ACCOUNTING column can only be chosen while the table is being created."
.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.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.added.iter().any(|column| column.name == column_name) {
return Err(format!("A column named `{column_name}` already exists."));
}
let quantity_ledger = self.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.currency_input)?
} else {
String::new()
};
self.added.push(ColumnDefinition {
name: column_name.clone(),
data_type: column_type,
indexed: self.indexing_input.trim().eq_ignore_ascii_case("yes"),
quantity_ledger,
money_mode: if has_currency {
MoneyMode::from_input(&self.rounding_input)
} else {
MoneyMode::Exact
},
currency,
});
self.clear_inputs();
Ok(format!("Column `{column_name}` added."))
}
fn clear_inputs(&mut self) {
self.name_input.clear();
self.type_input.clear();
self.temporal_type_input.clear();
self.gtin_type_input.clear();
self.decimal_precision_input.clear();
self.decimal_scale_input.clear();
self.indexing_input = "no".to_string();
self.quantity_ledger_input = "no".to_string();
self.rounding_input = "none".to_string();
self.currency_input = "EUR".to_string();
}
// ---- the columns added so far ----------------------------------------
/// Removes one column. The caller is what knows whether anything else
/// referenced it — `add_table` drops it from the display columns too.
pub(crate) fn remove(&mut self, index: usize) -> Result<ColumnDefinition, String> {
if index >= self.added.len() {
return Err("That column no longer exists.".to_string());
}
Ok(self.added.remove(index))
}
pub(crate) fn toggle_indexed(&mut self, index: usize) {
if let Some(column) = self.added.get_mut(index) {
column.indexed = !column.indexed;
}
}
pub(crate) fn selected_index_names(&self) -> Vec<String> {
self.added
.iter()
.filter(|column| column.indexed)
.map(|column| column.name.clone())
.collect()
}
pub(crate) fn is_empty(&self) -> bool {
self.added.is_empty()
}
/// Re-checks the columns themselves.
///
/// [`Self::add_from_inputs`] already applies these, but a draft rebuilt
/// from a posted form has not been through that path, so this is what a
/// tampered-with or truncated post is held to.
pub(crate) fn validate(&self) -> Result<(), String> {
for column in &self.added {
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 !self.accounting_allowed && column.data_type.eq_ignore_ascii_case("accounting") {
return Err(
"An ACCOUNTING column can only be chosen while the table is being created."
.to_string(),
);
}
// The same rule the server enforces: required for a money column,
// forbidden for every other type.
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(())
}
}
pub(crate) fn normalize_currency_input(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Err("Currency must be a three-letter ISO-4217 code".to_string());
}
Ok(currency)
}
/// 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;
}
if let Some(arguments) = field_type
.strip_prefix("decimal(")
.and_then(|rest| rest.strip_suffix(')'))
{
let Some((precision, scale)) = arguments.split_once(',') else {
return Some("`decimal` needs both a precision and a scale.".to_string());
};
return validate_decimal_arguments(precision.trim(), scale.trim()).err();
}
Some(format!("`{field_type}` is not a valid field type."))
}
/// The precision and scale rules the server applies to `decimal(p,s)`:
/// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`.
fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> {
let precision = validate_decimal_number("Precision", precision)?;
let scale = validate_decimal_number("Scale", scale)?;
if precision < 1 {
return Err("Precision must be at least 1.".to_string());
}
if scale > precision {
return Err("Scale cannot be greater than precision.".to_string());
}
Ok(())
}
fn validate_decimal_number(label: &str, value: &str) -> Result<u32, String> {
if value.is_empty() {
return Err(format!("{label} is required for a decimal column."));
}
if value.starts_with('+') || value.starts_with('-') {
return Err(format!("{label} cannot carry a sign."));
}
if value.contains('.') {
return Err(format!("{label} must be a whole number."));
}
if value.len() > 1 && value.starts_with('0') {
return Err(format!("{label} cannot have leading zeros."));
}
value
.parse::<u32>()
.map_err(|_| format!("{label} must be a whole number."))
}
/// The seam where the rules above meet the generated request types.
pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec<ProtoColumnDefinition> {
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()
}
/// 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",
];
/// The wire format of the column panel.
///
/// HTTP is stateless, so the whole panel travels with every interaction: the
/// pending inputs as scalars, and each already-added column as a set of
/// parallel repeated fields. `serde_html_form` decodes the repeats into
/// `Vec`s, which [`Self::to_draft`] zips back into a [`ColumnDraft`].
///
/// `add_table` posts these same field names as part of its larger form; see
/// [`columns_from_rows`], which is what both paths rebuild the list with.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ColumnForm {
/// Which panel button was pressed.
#[serde(default)]
pub action: String,
/// Row the action applies to, for the per-row buttons.
#[serde(default)]
pub index: Option<usize>,
#[serde(default)]
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 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,
#[serde(default)]
pub column_rounding_input: String,
#[serde(default)]
pub column_currency_input: String,
#[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>,
#[serde(default)]
pub column_currencies: Vec<String>,
}
impl ColumnForm {
pub(crate) fn to_draft(&self, accounting_allowed: bool) -> ColumnDraft {
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,
),
accounting_allowed,
}
}
}
fn is_yes(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("yes")
}
/// Zips the posted column vectors back into column definitions.
///
/// The 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 columns_from_rows(
names: &[String],
types: &[String],
indexed: &[String],
quantity_ledger: &[String],
rounding: &[String],
currencies: &[String],
) -> Vec<ColumnDefinition> {
let count = [
names.len(),
types.len(),
indexed.len(),
quantity_ledger.len(),
rounding.len(),
currencies.len(),
]
.into_iter()
.min()
.unwrap_or(0);
(0..count)
.map(|index| ColumnDefinition {
name: names[index].clone(),
data_type: types[index].clone(),
indexed: is_yes(&indexed[index]),
quantity_ledger: is_yes(&quantity_ledger[index]),
money_mode: MoneyMode::from_input(&rounding[index]),
currency: currencies[index].clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() {
let mut draft = ColumnDraft::new();
draft.name_input = "occurred_at".to_string();
draft.type_input = "temporal".to_string();
// Incomplete while no subtype is chosen.
assert_eq!(draft.canonical_type_input().unwrap(), None);
assert!(draft.show_temporal_type());
draft.temporal_type_input = "raw_datetime".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[0].data_type, "raw_datetime");
// Inputs are cleared for the next column.
assert!(draft.temporal_type_input.is_empty());
draft.name_input = "barcode".to_string();
draft.type_input = "gtin".to_string();
draft.gtin_type_input = "13".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[1].data_type, "gtin_13");
draft.name_input = "weight".to_string();
draft.type_input = "decimal".to_string();
assert_eq!(draft.canonical_type_input().unwrap(), None);
assert!(draft.show_decimal_arguments());
draft.decimal_precision_input = "12".to_string();
draft.decimal_scale_input = "3".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[2].data_type, "decimal(12,3)");
}
/// The precision and scale rules are the server's, so a draft that would
/// be refused there is refused here first.
#[test]
fn decimal_arguments_follow_the_servers_rules() {
let mut draft = ColumnDraft::new();
draft.name_input = "weight".to_string();
draft.type_input = "decimal".to_string();
for (precision, scale) in [("0", "0"), ("3", "5"), ("-2", "1"), ("08", "2"), ("4.5", "1")] {
draft.decimal_precision_input = precision.to_string();
draft.decimal_scale_input = scale.to_string();
assert!(
draft.add_from_inputs().is_err(),
"decimal({precision},{scale}) should be refused"
);
}
draft.decimal_precision_input = "10".to_string();
draft.decimal_scale_input = "0".to_string();
assert!(draft.add_from_inputs().is_ok());
}
/// `duration` and `period` are storable types on their own — the picker
/// offers them and nothing has to be resolved.
#[test]
fn duration_and_period_are_columns_of_their_own() {
for field_type in ["duration", "period"] {
let mut draft = ColumnDraft::new();
draft.name_input = "billing_span".to_string();
draft.type_input = field_type.to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[0].data_type, field_type);
}
}
#[test]
fn an_append_panel_refuses_an_accounting_column() {
let mut draft = ColumnDraft::for_append();
draft.type_input = "accounting".to_string();
assert!(draft.add_from_inputs().is_err());
assert!(!draft.offered_types().contains(&"accounting"));
// And again for a draft rebuilt from a posted form, which never went
// through `add_from_inputs`.
draft.added.push(ColumnDefinition {
name: "accounting".to_string(),
data_type: "accounting".to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: "EUR".to_string(),
});
assert!(draft.validate().is_err());
}
#[test]
fn invalid_identifiers_and_types_are_refused_at_add_time() {
let mut draft = ColumnDraft::new();
draft.type_input = "text".to_string();
draft.name_input = "Total".to_string();
assert!(draft.add_from_inputs().is_err());
draft.name_input = "customer_id".to_string();
assert!(draft.add_from_inputs().is_err());
draft.name_input = "created_at".to_string();
assert!(draft.add_from_inputs().is_err());
draft.name_input = "total".to_string();
draft.type_input = "timestamptz".to_string();
assert!(draft.add_from_inputs().is_err());
draft.type_input = "text".to_string();
assert!(draft.add_from_inputs().is_ok());
// Duplicates are refused too.
draft.name_input = "total".to_string();
draft.type_input = "text".to_string();
assert!(draft.add_from_inputs().is_err());
}
#[test]
fn quantity_ledger_requires_a_numeric_type() {
let mut draft = ColumnDraft::new();
draft.name_input = "note".to_string();
draft.type_input = "text".to_string();
draft.quantity_ledger_input = "yes".to_string();
assert!(draft.add_from_inputs().is_err());
draft.type_input = "int".to_string();
assert!(draft.add_from_inputs().is_ok());
assert!(draft.added[0].quantity_ledger);
// A parameterised decimal counts as numeric.
draft.name_input = "quantity".to_string();
draft.type_input = "decimal".to_string();
draft.decimal_precision_input = "12".to_string();
draft.decimal_scale_input = "3".to_string();
draft.quantity_ledger_input = "yes".to_string();
assert!(draft.add_from_inputs().is_ok());
}
#[test]
fn accounting_column_is_always_named_accounting() {
let mut draft = ColumnDraft::new();
draft.name_input = "whatever".to_string();
draft.type_input = "accounting".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[0].name, "accounting");
}
#[test]
fn money_columns_require_a_valid_currency() {
let mut draft = ColumnDraft::new();
draft.name_input = "total".to_string();
draft.type_input = "money".to_string();
draft.currency_input = "EU".to_string();
assert!(draft.add_from_inputs().is_err());
draft.currency_input = "eur".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[0].currency, "EUR");
}
#[test]
fn an_accounting_column_shows_its_currency_too() {
let mut draft = ColumnDraft::new();
draft.type_input = "accounting".to_string();
draft.currency_input = "czk".to_string();
draft.add_from_inputs().unwrap();
assert_eq!(draft.added[0].currency, "CZK");
assert_eq!(draft.added[0].option_label(), "CZK, exact");
draft.toggle_indexed(0);
assert_eq!(draft.added[0].option_label(), "indexed, CZK, exact");
}
/// `add_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 = ColumnDraft::new();
draft.added.push(ColumnDefinition {
name: "total".to_string(),
data_type: "money".to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate().is_err());
draft.added[0].currency = "XYZ".to_string();
assert!(draft.validate().is_err());
draft.added[0].currency = "EUR".to_string();
assert!(draft.validate().is_ok());
// Forbidden on everything else, exactly as the server has it.
draft.added[0].data_type = "text".to_string();
assert!(draft.validate().is_err());
}
#[test]
fn mismatched_column_vectors_never_mis_pair() {
let columns = columns_from_rows(
&["number".to_string(), "total".to_string()],
&["text".to_string()],
&["yes".to_string(), "no".to_string()],
&["no".to_string(), "no".to_string()],
&["exact".to_string(), "half-up".to_string()],
&[String::new(), "EUR".to_string()],
);
assert_eq!(columns.len(), 1);
assert_eq!(columns[0].name, "number");
assert_eq!(columns[0].data_type, "text");
assert!(columns[0].indexed);
}
#[test]
fn indexed_columns_become_the_index_list() {
let mut draft = ColumnDraft::new();
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
draft.add_from_inputs().unwrap();
draft.toggle_indexed(0);
assert_eq!(draft.selected_index_names(), vec!["number"]);
}
}

View File

@@ -1,4 +1,7 @@
use axum::http::HeaderMap;
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use tonic::{Request, metadata::MetadataValue};
#[derive(Debug)]
@@ -19,3 +22,16 @@ pub(crate) fn authenticated_request<T>(
request.metadata_mut().insert("authorization", value);
Ok(request)
}
/// Refuses a form POST that another site made the browser send.
///
/// The session cookie is `SameSite=Strict`, so a cross-site post arrives
/// without it and fails on authentication anyway; this turns that into a plain
/// refusal instead of a redirect to the login page, and covers every
/// state-changing endpoint the same way.
pub(crate) 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())
}

View File

@@ -161,6 +161,22 @@
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
.tag + .tag { margin-left: 4px; }
/* ---------- Table definition workspace ---------- */
/* Each operation is a .panel of its own, stacked; the deleting one is the
only place on the site that drops data, so it is coloured like it. */
.panel-subhead { margin: 22px 0 0; font-size: 13px; color: #33415c; }
.panel-actions { margin-top: 16px; }
.panel .form-grid { margin-top: 14px; }
.panel .form-actions { margin-top: 16px; }
.panel .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
.panel > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
.panel > button.secondary:hover { background: #e9edf4; }
.sql-preview { margin: 12px 0 0; padding: 12px; max-height: 260px; overflow: auto; border: 1px solid #e4e7ec; border-radius: 8px; font: 12px/1.5 ui-monospace, monospace; color: #33415c; background: #fafbfc; white-space: pre-wrap; }
.danger-panel { border-color: #eccfcf; }
.danger-panel h2 { color: #a12b2b; }
.form-actions button.danger-submit { background: #a12b2b; }
/* ---------- Narrow screens ---------- */
@media (max-width: 850px) {

View File

@@ -63,7 +63,7 @@
<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">
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
</label>
<label>Column type
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
@@ -71,56 +71,69 @@
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>
<option value="{{ column_type }}" {% if page.draft.columns.type_input == *column_type %}selected{% endif %}>{{ column_type }}</option>
{% endfor %}
</select>
</label>
{% if page.draft.show_temporal_type() %}
{% if page.draft.columns.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>
<option value="{{ temporal_type }}" {% if page.draft.columns.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.show_gtin_type() %}
{% if page.draft.columns.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>
<option value="{{ gtin_type }}" {% if page.draft.columns.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.show_money_options() %}
{% if page.draft.columns.show_decimal_arguments() %}
<label>Precision
<input name="decimal_precision_input" value="{{ page.draft.columns.decimal_precision_input }}"
inputmode="numeric" placeholder="12">
<small>Total digits stored, at least 1.</small>
</label>
<label>Scale
<input name="decimal_scale_input" value="{{ page.draft.columns.decimal_scale_input }}"
inputmode="numeric" placeholder="3">
<small>Digits after the point, no more than the precision.</small>
</label>
{% endif %}
{% if page.draft.columns.show_money_options() %}
<label>Currency
<input name="column_currency_input" value="{{ page.draft.column_currency_input }}" list="currency-codes"
<input name="column_currency_input" value="{{ page.draft.columns.currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
<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>
<option value="none" {% if page.draft.columns.rounding_input != "half-up" %}selected{% endif %}>none</option>
<option value="half-up" {% if page.draft.columns.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>
<option value="no" {% if page.draft.columns.indexing_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.columns.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>
<option value="no" {% if page.draft.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
</select>
<small>INT, BIGINT, DECIMAL or MONEY only.</small>
</label>
@@ -130,14 +143,14 @@
</section>
<section class="builder-section">
<h2>Columns <span class="count">{{ page.draft.columns.len() }}</span></h2>
<h2>Columns <span class="count">{{ page.draft.columns.added.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 %}
{% for column in page.draft.columns.added %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.data_type }}</td>
@@ -164,7 +177,7 @@
{% endif %}
{# The draft itself: one set of fields per column, in order. #}
{% for column in page.draft.columns %}
{% for column in page.draft.columns.added %}
<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 %}">

View File

@@ -12,6 +12,7 @@
<p>Browse profiles, tables, and their physical columns.</p>
</div>
<div class="actions">
<a href="/admin/table-definition">Table definition</a>
<a href="/admin/tables/new">Add table</a>
<a href="/admin/logic/new">Add logic</a>
<a href="/admin/validation/new">Add validation</a>

View File

@@ -0,0 +1,155 @@
{#
The append-columns panel —
crate::pages::admin::table_definition::ui::ColumnPanelFragment, and what
workspace.html embeds inside #column-form.
Staging a column writes nothing, so these buttons swap this panel alone.
Saving is the form's own submit, which swaps the whole workspace. The field
names are the ones crate::schema::ColumnForm declares — the same set the
Add-table builder posts, decoded by the same code.
#}
{#
Staging swaps this panel and nothing else, so a refused column has nowhere
else to be reported. When the workspace embeds the panel it reports the
outcome at the top instead, and this block stays out of the way.
#}
{% import "ui/alert.html" as panel_alert %}
{% import "ui/dialog.html" as panel_dialog %}
{% if standalone_column_panel %}
{%- if let Some(message) = page.error %}{% call panel_alert::error("Could not add the column", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call panel_alert::success("Staged", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call panel_dialog::error("Could not add the column", message) %}{% endcall %}{% endif -%}
{% endif %}
<div class="form-grid">
<label>Column name
<input name="column_name_input" value="{{ page.columns.name_input }}" placeholder="issued_on">
</label>
<label>Column type
<select name="column_type_input"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-trigger="change" hx-include="#column-form"
hx-target="#column-panel" 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.columns.type_input == *column_type %}selected{% endif %}>{{ column_type }}</option>
{% endfor %}
</select>
</label>
{% if page.columns.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.columns.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.columns.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.columns.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.columns.show_decimal_arguments() %}
<label>Precision
<input name="decimal_precision_input" value="{{ page.columns.decimal_precision_input }}"
inputmode="numeric" placeholder="12">
<small>Total digits stored, at least 1.</small>
</label>
<label>Scale
<input name="decimal_scale_input" value="{{ page.columns.decimal_scale_input }}"
inputmode="numeric" placeholder="3">
<small>Digits after the point, no more than the precision.</small>
</label>
{% endif %}
{% if page.columns.show_money_options() %}
<label>Currency
<input name="column_currency_input" value="{{ page.columns.currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
<label>Rounding
<select name="column_rounding_input">
<option value="none" {% if page.columns.rounding_input != "half-up" %}selected{% endif %}>none</option>
<option value="half-up" {% if page.columns.rounding_input == "half-up" %}selected{% endif %}>half-up</option>
</select>
</label>
{% endif %}
<label>Indexing
<select name="column_indexing_input">
<option value="no" {% if page.columns.indexing_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.columns.indexing_input == "yes" %}selected{% endif %}>yes</option>
</select>
</label>
<label>Quantity ledger
<select name="column_quantity_ledger_input">
<option value="no" {% if page.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.columns.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/table-definition/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "add-column"}'>Stage column</button>
{% if page.columns.added.is_empty() %}
<p class="hint">No columns staged yet. Describe one above and press <em>Stage 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.columns.added %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.data_type }}</td>
<td>
<button type="button" class="toggle"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" 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/table-definition/columns/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# The staged columns themselves: one set of fields per column, in order. #}
{% for column in page.columns.added %}
<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() }}">
<input type="hidden" name="column_currencies" value="{{ column.currency }}">
{% endfor %}
<div class="form-actions">
<button type="submit">Add {{ page.columns.added.len() }} column(s) to the table</button>
</div>

View File

@@ -0,0 +1,32 @@
{# GET /admin/table-definition — crate::pages::admin::table_definition::ui::TableDefinitionPage #}
{% extends "ui/base.html" %}
{% block title %}Table definition{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Table definition</p>
<h1>Table definition</h1>
<p>Pick a profile and a table, then change its definition.</p>
</div>
<div class="actions">
<a href="/admin">← Admin panel</a>
</div>
</section>
{#
One swap target for the whole workspace. Every write answers with this
markup re-read from the backend, so the screen after a change is the
definition as it now is, not the form that was submitted.
#}
<div id="table-definition-workspace" aria-live="polite">
{% include "pages/admin/table_definition/workspace.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -0,0 +1,303 @@
{#
The whole workspace — crate::pages::admin::table_definition::ui::WorkspaceFragment,
and what table_definition.html embeds on first load.
Which panels appear follows how much has been selected: the copy and invoice
template panels need a profile, everything else needs a table, and a
backend-managed ("system") table gets none of the write panels at all,
because the server refuses every one of them for it.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call alert::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %}
{% if !sql.is_empty() %}
<section class="panel">
<h2>SQL the backend ran</h2>
<pre class="sql-preview">{{ sql }}</pre>
</section>
{% endif %}
{% endif %}
{% if !page.generated.is_empty() %}
<section class="panel">
<h2>Tables created from the template</h2>
<table class="builder-table">
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
<tbody>
{% for generated in page.generated %}
<tr>
<td><code>{{ generated.table_name }}</code></td>
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint"></span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="panel">
<h2>Selection</h2>
<div class="form-grid">
<label>Profile
<select name="profile" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<option value="">Choose a profile</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selection.profile == *profile %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
</select>
</label>
{% if page.selection.has_profile() %}
<label>Table
<select name="table" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-include="[name='profile']">
<option value="">Choose a table</option>
{% for table in page.tables %}
<option value="{{ table.name }}" {% if page.selection.table == table.name %}selected{% endif %}>
{{ table.name }}{% if table.is_system() %} (system){% endif %}
</option>
{% endfor %}
</select>
</label>
{% endif %}
</div>
{% if page.selection.has_profile() %}
<div class="actions panel-actions">
<a href="/admin/tables/new?profile={{ page.selection.profile }}">+ Create a table in this profile</a>
</div>
{% if page.tables.is_empty() %}
<p class="hint">This profile has no tables yet.</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Kind</th><th>Depends on</th><th>Row display</th></tr></thead>
<tbody>
{% for table in page.tables %}
<tr>
<td><code>{{ table.name }}</code></td>
<td>{{ table.table_kind }}</td>
<td>{% if table.depends_on.is_empty() %}<span class="hint"></span>{% else %}{{ table.depends_on|join(", ") }}{% endif %}</td>
<td>{% if table.row_display_columns.is_empty() %}<span class="hint">id</span>{% else %}{{ table.row_display_columns|join(", ") }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="hint">Nothing is selected yet. Every panel below acts on one profile, and most of them on one table inside it.</p>
{% endif %}
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>{{ detail.name }} <span class="count">{{ detail.columns.len() }} columns</span></h2>
<p class="hint">
Identified by
{%- if detail.row_display_columns.is_empty() %} its id
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
{%- if let Some(summary) = page.selected_table() -%}
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
{%- endif -%}
.
</p>
{% if detail.is_system() %}
<p class="hint">This table is backend-managed. Its definition is shown here, but it can only be changed through the backend's own APIs.</p>
{% endif %}
<table class="builder-table">
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}</td>
<td>
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if !detail.scripts.is_empty() %}
<h3 class="panel-subhead">Scripts</h3>
<table class="builder-table">
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
{% for script in detail.scripts %}
<tr>
<td><code>{{ script.target_column }}</code></td>
<td>{{ script.target_column_type }}</td>
<td>
{{ script.description }}
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}
{% if page.table_is_writable() %}
{#
Append columns. The panel stages columns without writing anything; the
selection travels in the URL so the posted fields are exactly the ones the
Add-table builder posts, and both are decoded by the same code.
#}
<section class="panel">
<h2>Add columns to <code>{{ page.selection.table }}</code></h2>
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
<form id="column-form"
hx-post="/admin/table-definition/columns{{ page.selection.query() }}"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>Rename a column</h2>
<p class="hint">Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.</p>
<form hx-post="/admin/table-definition/rename"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label>Column
<select name="old_column_name">
<option value="">Choose a column</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>New name
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
</div>
<div class="form-actions">
<button type="submit">Rename column</button>
</div>
</form>
</section>
{% endif %}
<section class="panel danger-panel">
<h2>Delete <code>{{ page.selection.table }}</code></h2>
<p class="hint">
Drops the table and its definition, and the profile too when this was its
last table. The backend refuses to delete a table that still has rows.
</p>
<form hx-post="/admin/table-definition/delete"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="danger-submit">Delete table</button>
</div>
</form>
</section>
{% endif %}
{% if page.selection.has_profile() %}
<section class="panel">
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
<form hx-post="/admin/table-definition/copy"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>New profile name
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
</label>
</div>
{% if !page.copy_candidates().is_empty() %}
<div class="check-group">
{% for table in page.copy_candidates() %}
<label class="check">
<input type="checkbox" name="table_names" value="{{ table.name }}"
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
{{ table.name }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="form-actions">
<button type="submit">Copy profile</button>
</div>
</form>
</section>
<section class="panel">
<h2>Create tables from an invoice template</h2>
<p class="hint">
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
template. A path that matches an existing table and column becomes a
reference to it; anything else becomes a TEXT column to refine later, and
every <code>[]</code> becomes a child table.
</p>
<form hx-post="/admin/table-definition/invoice-template"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>Table name
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
</label>
<label>Row display columns
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
<small>Comma-separated, and only for the root table.</small>
</label>
<label class="wide">Template source
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = (&#10; &quot;sidlo.nazov&quot;,&#10; &quot;people[].name&quot;,&#10;)">{{ page.invoice.typst_source }}</textarea>
</label>
</div>
<div class="form-actions">
<button type="submit">Create from template</button>
</div>
</form>
</section>
<section class="panel">
<h2>Column rename history</h2>
{% if page.history.is_empty() %}
<p class="hint">
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
<tbody>
{% for entry in page.history %}
<tr>
<td><code>{{ entry.table_name }}</code></td>
<td><code>{{ entry.old_column_name }}</code></td>
<td><code>{{ entry.new_column_name }}</code></td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}