add table is better now

This commit is contained in:
Priec
2026-08-03 14:44:12 +02:00
parent c8afe99d79
commit 023f8c9dcb
11 changed files with 1799 additions and 212 deletions

40
Cargo.lock generated
View File

@@ -610,6 +610,31 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base16ct" name = "base16ct"
version = "0.2.0" version = "0.2.0"
@@ -6546,6 +6571,19 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.150"
@@ -8727,8 +8765,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"askama", "askama",
"axum", "axum",
"axum-extra",
"prost", "prost",
"prost-types", "prost-types",
"rusty-money",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",

View File

@@ -6,6 +6,10 @@ edition = "2024"
[dependencies] [dependencies]
askama = "0.15.1" askama = "0.15.1"
axum = "0.8" axum = "0.8"
# `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`, and
# the table builder posts one set of fields per already-added column.
axum-extra = { version = "0.10", features = ["form"] }
rusty-money = "0.5.0"
prost = "0.14.4" prost = "0.14.4"
prost-types = "0.14.4" prost-types = "0.14.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,939 @@
//! The Add-table draft and its rules.
//!
//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics
//! with the terminal-specific parts removed (`ratatui` cursors, the canvas
//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below —
//! the type catalogue, canonicalisation, field visibility and validation —
//! matches the client so the two frontends accept and reject exactly the same
//! table definitions.
//!
//! Keeping it dependency-light is deliberate: this module is the candidate for
//! extraction into a crate shared by `client`, `web` and `server`. Everything
//! above [`TableDraft::into_request`] is already proto-free; that one method is
//! the seam where a shared crate would hand back a plain draft for each
//! frontend to map to its own generated request type.
use crate::definitions::table_definition::{
ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest,
TableLink as ProtoTableLink,
};
/// Column types offered in the type picker. `temporal` and `gtin` are pickers
/// of their own: neither is a storable type, each resolves to a subtype below.
pub(crate) const COLUMN_TYPES: &[&str] = &[
"text",
"boolean",
"money",
"accounting",
"int",
"temporal",
"phone",
"iban",
"email_address",
"credit_card",
"gtin",
"bigint",
];
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
/// Every type the server accepts, i.e. what a canonicalised column may be.
const CANONICAL_TYPES: &[&str] = &[
"text",
"boolean",
"date",
"time",
"instant",
"raw_datetime",
"phone",
"iban",
"email_address",
"credit_card",
"gtin_8",
"gtin_12",
"gtin_13",
"gtin_14",
"money",
"accounting",
"int",
"bigint",
];
/// Types a quantity-ledger column may use.
fn quantity_ledger_type_allowed(field_type: &str) -> bool {
matches!(field_type, "int" | "bigint" | "money")
|| (field_type.starts_with("decimal(") && field_type.ends_with(')'))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum MoneyMode {
#[default]
Exact,
Rounded,
}
impl MoneyMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::Rounded => "half-up",
}
}
fn from_input(value: &str) -> Self {
if value.trim().eq_ignore_ascii_case("half-up") {
Self::Rounded
} else {
Self::Exact
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum LinkMode {
#[default]
None,
Optional,
Required,
}
impl LinkMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::None => "none",
Self::Optional => "optional",
Self::Required => "required",
}
}
/// Cycles none → optional → required → none, as `Select` does in the TUI.
pub(crate) fn next(self) -> Self {
match self {
Self::None => Self::Optional,
Self::Optional => Self::Required,
Self::Required => Self::None,
}
}
pub(crate) fn from_label(value: &str) -> Self {
match value.trim() {
"optional" => Self::Optional,
"required" => Self::Required,
_ => Self::None,
}
}
pub(crate) fn is_active(self) -> bool {
!matches!(self, Self::None)
}
pub(crate) fn is_required(self) -> bool {
matches!(self, Self::Required)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ColumnDefinition {
pub name: String,
pub data_type: String,
pub indexed: bool,
pub quantity_ledger: bool,
pub money_mode: MoneyMode,
}
impl ColumnDefinition {
/// The `option` cell of the preview, mirroring the client's preview table.
pub(crate) fn option_label(&self) -> String {
let is_money = self.data_type.eq_ignore_ascii_case("money");
match (self.indexed, is_money) {
(true, true) => format!("indexed, {}", self.money_mode.label()),
(true, false) => "indexed".to_string(),
(false, true) => self.money_mode.label().to_string(),
(false, false) => String::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkDefinition {
pub linked_table_name: String,
pub mode: LinkMode,
}
/// One row of the "Table definition preview" — the schema as it will exist.
pub(crate) struct PreviewRow {
pub mark: String,
pub column: String,
pub data_type: String,
pub option: String,
pub source: String,
}
/// The whole Add-table page state, minus presentation.
#[derive(Clone, Debug, Default)]
pub(crate) struct TableDraft {
/// Profile this table belongs to when an existing one was picked.
pub profile_name: String,
/// Profile name typed in when creating a new profile.
pub profile_name_input: String,
pub creating_new_profile: bool,
pub accounting_currency: String,
pub table_name: String,
pub base_currency: String,
// The column-input panel: one pending column being described.
pub column_name_input: String,
pub column_type_input: String,
pub temporal_type_input: String,
pub gtin_type_input: String,
pub column_indexing_input: String,
pub column_quantity_ledger_input: String,
pub column_rounding_input: String,
pub columns: Vec<ColumnDefinition>,
pub links: Vec<LinkDefinition>,
/// Columns identifying a row to users, in the order they are shown.
/// Empty means rows are identified by their id alone.
pub row_display_columns: Vec<String>,
/// Tables already defined in the target profile — a new table may not
/// reuse one of these names.
pub existing_profile_tables: Vec<String>,
}
impl TableDraft {
/// A draft for a brand-new page load, matching the client's defaults.
pub(crate) fn new() -> Self {
Self {
accounting_currency: "EUR".to_string(),
base_currency: "EUR".to_string(),
column_indexing_input: "no".to_string(),
column_quantity_ledger_input: "no".to_string(),
column_rounding_input: "none".to_string(),
..Self::default()
}
}
// ---- pending-column input -------------------------------------------
pub(crate) fn is_money_column_input(&self) -> bool {
let value = self.column_type_input.trim();
value.eq_ignore_ascii_case("money") || value.eq_ignore_ascii_case("accounting")
}
pub(crate) fn is_temporal_column_input(&self) -> bool {
self.column_type_input.trim().eq_ignore_ascii_case("temporal")
}
pub(crate) fn is_gtin_column_input(&self) -> bool {
self.column_type_input.trim().eq_ignore_ascii_case("gtin")
}
/// The storable type the pending inputs describe, resolving the `temporal`
/// and `gtin` pickers to their subtype. `None` while the choice is still
/// incomplete.
fn canonical_column_type_input(&self) -> Option<String> {
let column_type = self.column_type_input.trim().to_ascii_lowercase();
match column_type.as_str() {
"temporal" => {
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
TEMPORAL_TYPES
.contains(&temporal_type.as_str())
.then_some(temporal_type)
}
"gtin" => {
let gtin_type = self.gtin_type_input.trim();
GTIN_TYPES
.contains(&gtin_type)
.then(|| format!("gtin_{gtin_type}"))
}
"" => None,
_ => Some(column_type),
}
}
// ---- field visibility (the same rules the TUI canvas applies) --------
pub(crate) fn show_profile_name_input(&self) -> bool {
self.creating_new_profile
}
pub(crate) fn show_accounting_currency(&self) -> bool {
self.creating_new_profile
}
pub(crate) fn show_temporal_type(&self) -> bool {
self.is_temporal_column_input()
}
pub(crate) fn show_gtin_type(&self) -> bool {
self.is_gtin_column_input()
}
pub(crate) fn show_rounding(&self) -> bool {
self.is_money_column_input()
}
pub(crate) fn show_base_currency(&self) -> bool {
self.is_money_column_input() || self.money_column_count() > 0
}
// ---- mutations -------------------------------------------------------
/// Appends the pending column, then clears the input panel.
pub(crate) fn add_column_from_inputs(&mut self) -> Result<String, String> {
let Some(column_type) = self.canonical_column_type_input() else {
return Err("Both a column name and a column type are required.".to_string());
};
// An accounting column is always named `accounting`.
let column_name = if column_type.eq_ignore_ascii_case("accounting") {
"accounting".to_string()
} else {
self.column_name_input.trim().to_string()
};
if column_name.is_empty() {
return Err("Both a column name and a column type are required.".to_string());
}
if let Some(error) = validate_identifier(&column_name, "Column name", true) {
return Err(error);
}
if let Some(error) = validate_field_type(&column_type) {
return Err(error);
}
if self.columns.iter().any(|column| column.name == column_name) {
return Err(format!("A column named `{column_name}` already exists."));
}
let quantity_ledger = self
.column_quantity_ledger_input
.trim()
.eq_ignore_ascii_case("yes");
if quantity_ledger && !quantity_ledger_type_allowed(&column_type) {
return Err(
"Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(),
);
}
let is_money = column_type.eq_ignore_ascii_case("money")
|| column_type.eq_ignore_ascii_case("accounting");
self.columns.push(ColumnDefinition {
name: column_name.clone(),
data_type: column_type,
indexed: self
.column_indexing_input
.trim()
.eq_ignore_ascii_case("yes"),
quantity_ledger,
money_mode: if is_money {
MoneyMode::from_input(&self.column_rounding_input)
} else {
MoneyMode::Exact
},
});
self.clear_column_inputs();
Ok(format!("Column `{column_name}` added."))
}
fn clear_column_inputs(&mut self) {
self.column_name_input.clear();
self.column_type_input.clear();
self.temporal_type_input.clear();
self.gtin_type_input.clear();
self.column_indexing_input = "no".to_string();
self.column_quantity_ledger_input = "no".to_string();
self.column_rounding_input = "none".to_string();
}
/// Removes one column, and drops it from the display columns with it.
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
if index >= self.columns.len() {
return Err("That column no longer exists.".to_string());
}
let removed = self.columns.remove(index);
self.row_display_columns
.retain(|display| display != &removed.name);
Ok(format!("Column `{}` removed.", removed.name))
}
pub(crate) fn toggle_column_indexed(&mut self, index: usize) {
if let Some(column) = self.columns.get_mut(index) {
column.indexed = !column.indexed;
}
}
pub(crate) fn cycle_link_mode(&mut self, index: usize) {
if let Some(link) = self.links.get_mut(index) {
link.mode = link.mode.next();
}
}
/// Adds or removes one display-column candidate.
///
/// Index 0 is `id`, which is not a display column of its own: choosing it
/// clears the list, since an empty list already means "identified by id".
/// Any other index toggles that column, appending so the order columns were
/// chosen in is the order they are shown in.
pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) {
if index == 0 {
self.row_display_columns.clear();
return;
}
let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else {
return;
};
match self
.row_display_columns
.iter()
.position(|display| *display == column)
{
Some(position) => {
self.row_display_columns.remove(position);
}
None => self.row_display_columns.push(column),
}
}
/// Rebuilds the link list from the tables available in the target profile,
/// keeping whatever mode each surviving link already had.
pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec<String>) {
let previous_modes = self
.links
.iter()
.map(|link| (link.linked_table_name.clone(), link.mode))
.collect::<std::collections::HashMap<_, _>>();
self.links = table_names
.into_iter()
.filter(|table_name| table_name != &self.table_name)
.map(|linked_table_name| LinkDefinition {
mode: previous_modes
.get(&linked_table_name)
.copied()
.unwrap_or(LinkMode::None),
linked_table_name,
})
.collect();
}
// ---- derived state ---------------------------------------------------
/// The profile name to validate and persist: the typed input while creating
/// a new profile, otherwise the profile that was picked.
pub(crate) fn effective_profile_name(&self) -> String {
if self.creating_new_profile {
self.profile_name_input.trim().to_string()
} else {
self.profile_name.clone()
}
}
pub(crate) fn table_name_conflicts(&self) -> bool {
!self.table_name.is_empty()
&& self
.existing_profile_tables
.iter()
.any(|name| name == &self.table_name)
}
pub(crate) fn money_column_count(&self) -> usize {
self.columns
.iter()
.filter(|column| {
column.data_type.eq_ignore_ascii_case("money")
|| column.data_type.eq_ignore_ascii_case("accounting")
})
.count()
}
pub(crate) fn selected_index_names(&self) -> Vec<String> {
self.columns
.iter()
.filter(|column| column.indexed)
.map(|column| column.name.clone())
.collect()
}
/// Position of `column` among the display columns, counting from 1.
pub(crate) fn row_display_position(&self, column: &str) -> Option<usize> {
self.row_display_columns
.iter()
.position(|display| display == column)
.map(|index| index + 1)
}
/// The schema as it will exist: system columns, relation columns, then the
/// user's own. Mirrors the client's preview pane.
pub(crate) fn preview_rows(&self) -> Vec<PreviewRow> {
let mut rows = vec![
PreviewRow {
mark: if self.row_display_columns.is_empty() {
"[x]".to_string()
} else {
"[ ]".to_string()
},
column: "id".to_string(),
data_type: "BIGSERIAL".to_string(),
option: "primary key".to_string(),
source: "system".to_string(),
},
PreviewRow {
mark: String::new(),
column: "deleted".to_string(),
data_type: "BOOLEAN".to_string(),
option: "default false".to_string(),
source: "system".to_string(),
},
];
for link in self.links.iter().filter(|link| link.mode.is_active()) {
rows.push(PreviewRow {
mark: String::new(),
column: format!("{}_id", link.linked_table_name),
data_type: "BIGINT".to_string(),
option: if link.mode.is_required() {
"required".to_string()
} else {
"optional".to_string()
},
source: "relation".to_string(),
});
}
for column in &self.columns {
rows.push(PreviewRow {
mark: self
.row_display_position(&column.name)
.map(|position| format!("[{position}]"))
.unwrap_or_else(|| "[ ]".to_string()),
column: column.name.clone(),
data_type: column.data_type.clone(),
option: column.option_label(),
source: "user".to_string(),
});
}
rows.push(PreviewRow {
mark: String::new(),
column: "created_at".to_string(),
data_type: "TIMESTAMPTZ".to_string(),
option: "current time".to_string(),
source: "system".to_string(),
});
rows
}
// ---- validation and submission ---------------------------------------
/// Every check the client runs before it will save.
pub(crate) fn validate(&self) -> Result<(), String> {
let profile_name = self.effective_profile_name();
if self.creating_new_profile && profile_name.is_empty() {
return Err("Enter a name for the new profile.".to_string());
}
if let Some(error) = validate_identifier(&profile_name, "Profile name", false) {
return Err(error);
}
if let Some(error) = validate_accounting_currency(self) {
return Err(error);
}
if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) {
return Err(error);
}
if self.table_name_conflicts() {
return Err(format!(
"A table named `{}` already exists in profile `{}`.",
self.table_name, profile_name
));
}
if self.columns.is_empty() {
return Err("Add at least one column before saving.".to_string());
}
for column in &self.columns {
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
return Err(error);
}
if let Some(error) = validate_field_type(&column.data_type) {
return Err(format!("Column `{}`: {error}", column.name));
}
}
if let Some(error) = validate_base_currency(self) {
return Err(error);
}
Ok(())
}
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
self.table_name = self.table_name.trim().to_string();
self.validate()?;
Ok(PostTableDefinitionRequest {
table_name: self.table_name.clone(),
profile_name: self.effective_profile_name(),
columns: self
.columns
.iter()
.map(|column| ProtoColumnDefinition {
name: column.name.clone(),
field_type: column.data_type.clone(),
rounding: match column.money_mode {
MoneyMode::Rounded => MoneyRounding::HalfUp.into(),
MoneyMode::Exact => MoneyRounding::None.into(),
},
quantity_ledger: column.quantity_ledger,
})
.collect(),
indexes: self.selected_index_names(),
links: self
.links
.iter()
.filter(|link| link.mode.is_active())
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
required: link.mode.is_required(),
})
.collect(),
base_currency: if self.money_column_count() == 0 {
String::new()
} else {
self.base_currency.trim().to_ascii_uppercase()
},
accounting_currency: if self.creating_new_profile {
self.accounting_currency.trim().to_ascii_uppercase()
} else {
String::new()
},
row_display_columns: self.row_display_columns.clone(),
})
}
}
pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option<String> {
if draft.money_column_count() == 0 {
return None;
}
let currency = draft.base_currency.trim();
if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) {
return Some("Base currency must be a three-letter ISO-4217 code".to_string());
}
None
}
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
if !draft.creating_new_profile {
return None;
}
let currency = draft.accounting_currency.to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Some("Accounting currency must be a three-letter ISO-4217 code".to_string());
}
None
}
/// PostgreSQL identifier rules, plus the names this schema reserves.
pub(crate) fn validate_identifier(
value: &str,
label: &str,
reject_table_reserved: bool,
) -> Option<String> {
if value.is_empty() {
return Some(format!("{label} cannot be empty."));
}
if value != value.trim() {
return Some(format!("{label} cannot start or end with a space."));
}
if value.starts_with('_') {
return Some(format!("{label} cannot start with an underscore."));
}
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(format!("{label} cannot start with a number."));
}
if value.len() > 63 {
return Some(format!("{label} cannot be longer than 63 characters."));
}
if value
.chars()
.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_')
{
return Some(format!(
"{label} may only use lowercase letters, digits and underscores."
));
}
if reject_table_reserved
&& (value == "id"
|| value == "deleted"
|| value == "created_at"
|| value == "row_revision"
|| value.ends_with("_id"))
{
return Some(format!("{label} uses a reserved name."));
}
if !reject_table_reserved
&& (value == "public" || value == "information_schema" || value.starts_with("pg_"))
{
return Some("That profile name is reserved by PostgreSQL.".to_string());
}
None
}
pub(crate) fn validate_field_type(field_type: &str) -> Option<String> {
let field_type = field_type.to_lowercase();
if CANONICAL_TYPES.contains(&field_type.as_str()) {
return None;
}
Some(format!("`{field_type}` is not a valid field type."))
}
/// ISO-4217 codes offered as currency suggestions, matching the client's list.
pub(crate) const CURRENCY_CODES: &[&str] = &[
"EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM",
"BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN",
"BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK",
"DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF",
"GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD",
"JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP",
"LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR",
"MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB",
"PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD",
"SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW",
"UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD",
"XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK",
"ZWL",
];
#[cfg(test)]
mod tests {
use super::*;
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
let mut draft = TableDraft::new();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.push(ColumnDefinition {
name: name.to_string(),
data_type: data_type.to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
});
draft
}
#[test]
fn temporal_and_gtin_pickers_resolve_to_canonical_types() {
let mut draft = TableDraft::new();
draft.column_name_input = "occurred_at".to_string();
draft.column_type_input = "temporal".to_string();
// Incomplete while no subtype is chosen.
assert!(draft.canonical_column_type_input().is_none());
assert!(draft.show_temporal_type());
draft.temporal_type_input = "raw_datetime".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].data_type, "raw_datetime");
// Inputs are cleared for the next column.
assert!(draft.temporal_type_input.is_empty());
draft.column_name_input = "barcode".to_string();
draft.column_type_input = "gtin".to_string();
draft.gtin_type_input = "13".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[1].data_type, "gtin_13");
}
#[test]
fn invalid_identifiers_and_types_are_refused_at_add_time() {
let mut draft = TableDraft::new();
draft.column_type_input = "text".to_string();
draft.column_name_input = "Total".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "customer_id".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "created_at".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_name_input = "total".to_string();
draft.column_type_input = "timestamptz".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_type_input = "text".to_string();
assert!(draft.add_column_from_inputs().is_ok());
// Duplicates are refused too.
draft.column_name_input = "total".to_string();
draft.column_type_input = "text".to_string();
assert!(draft.add_column_from_inputs().is_err());
}
#[test]
fn quantity_ledger_requires_a_numeric_type() {
let mut draft = TableDraft::new();
draft.column_name_input = "note".to_string();
draft.column_type_input = "text".to_string();
draft.column_quantity_ledger_input = "yes".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.column_type_input = "int".to_string();
assert!(draft.add_column_from_inputs().is_ok());
assert!(draft.columns[0].quantity_ledger);
}
#[test]
fn accounting_column_is_always_named_accounting() {
let mut draft = TableDraft::new();
draft.column_name_input = "whatever".to_string();
draft.column_type_input = "accounting".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].name, "accounting");
}
#[test]
fn money_columns_require_a_valid_base_currency() {
let mut draft = draft_with_column("total", "money");
draft.base_currency = "EU".to_string();
assert!(draft.validate().is_err());
draft.base_currency = "eur".to_string();
assert!(draft.validate().is_ok());
assert_eq!(draft.into_request().unwrap().base_currency, "EUR");
}
#[test]
fn new_profile_accounting_currency_must_exist_in_the_iso_registry() {
let mut draft = draft_with_column("total", "int");
draft.creating_new_profile = true;
draft.profile_name_input = "billing".to_string();
draft.accounting_currency = "AAA".to_string();
assert!(draft.validate().is_err());
draft.accounting_currency = "eur".to_string();
assert!(draft.validate().is_ok());
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
}
#[test]
fn existing_profile_sends_no_accounting_currency() {
let draft = draft_with_column("total", "int");
let request = draft.into_request().unwrap();
assert_eq!(request.accounting_currency, "");
assert_eq!(request.profile_name, "billing");
// No money column, so no base currency either.
assert_eq!(request.base_currency, "");
}
#[test]
fn duplicate_table_names_in_the_profile_are_refused() {
let mut draft = draft_with_column("total", "int");
draft.existing_profile_tables = vec!["invoice".to_string()];
assert!(draft.validate().is_err());
}
#[test]
fn reserved_profile_names_are_refused() {
let mut draft = draft_with_column("total", "int");
draft.profile_name = "pg_catalog".to_string();
assert!(draft.validate().is_err());
}
#[test]
fn links_keep_their_mode_when_the_table_list_is_reloaded() {
let mut draft = draft_with_column("total", "int");
draft.set_available_relation_tables(vec!["customer".into(), "project".into()]);
draft.cycle_link_mode(0); // none -> optional
draft.cycle_link_mode(0); // optional -> required
draft.set_available_relation_tables(vec![
"customer".into(),
"project".into(),
"address".into(),
]);
assert_eq!(draft.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[2].mode, LinkMode::None);
let request = draft.into_request().unwrap();
assert_eq!(request.links.len(), 1);
assert_eq!(request.links[0].linked_table_name, "customer");
assert!(request.links[0].required);
}
#[test]
fn a_table_never_links_to_itself() {
let mut draft = draft_with_column("total", "int");
draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]);
assert_eq!(draft.links.len(), 1);
assert_eq!(draft.links[0].linked_table_name, "customer");
}
#[test]
fn row_display_columns_toggle_in_the_order_they_were_chosen() {
let mut draft = draft_with_column("number", "text");
draft.columns.push(ColumnDefinition {
name: "issued_on".to_string(),
data_type: "date".to_string(),
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
});
draft.toggle_row_display_candidate(2); // issued_on
draft.toggle_row_display_candidate(1); // number
assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]);
assert_eq!(draft.row_display_position("number"), Some(2));
// Index 0 is `id`: choosing it clears the list.
draft.toggle_row_display_candidate(0);
assert!(draft.row_display_columns.is_empty());
}
#[test]
fn removing_a_column_drops_it_from_the_display_columns() {
let mut draft = draft_with_column("number", "text");
draft.toggle_row_display_candidate(1);
assert_eq!(draft.row_display_columns, vec!["number"]);
draft.remove_column(0).unwrap();
assert!(draft.row_display_columns.is_empty());
}
#[test]
fn the_preview_shows_system_relation_and_user_columns() {
let mut draft = draft_with_column("number", "text");
draft.set_available_relation_tables(vec!["customer".into()]);
draft.cycle_link_mode(0);
let rows = draft.preview_rows();
let columns = rows
.iter()
.map(|row| row.column.as_str())
.collect::<Vec<_>>();
assert_eq!(
columns,
vec!["id", "deleted", "customer_id", "number", "created_at"]
);
assert_eq!(rows[2].option, "optional");
// No display column chosen, so `id` identifies the row.
assert_eq!(rows[0].mark, "[x]");
}
#[test]
fn indexed_columns_become_the_index_list() {
let mut draft = draft_with_column("number", "text");
draft.toggle_column_indexed(0);
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
}
}

View File

@@ -1,22 +1,27 @@
use axum::http::HeaderMap; use axum::http::HeaderMap;
use crate::{ use crate::{
AppState, AppState, auth::GetAuthorizationRequest, definitions::common::Empty,
auth::GetAuthorizationRequest,
definitions::common::Empty,
services::authenticated_request, services::authenticated_request,
}; };
use super::state::{AddTablePageState, CreateTableForm}; use super::{draft::TableDraft, state::AddTablePageState};
/// Loads everything the builder needs around the draft: the profiles that can
/// be picked, and — for whichever profile the table will belong to — the tables
/// that are link targets and the names the new table may not reuse.
///
/// The link targets and reserved names always come from the live profile tree,
/// never from the posted form, so they cannot be spoofed by a crafted request.
pub(crate) async fn load_page( pub(crate) async fn load_page(
state: AppState, state: AppState,
headers: &HeaderMap, headers: &HeaderMap,
form: CreateTableForm, mut draft: TableDraft,
status: Option<String>,
error: Option<String>, error: Option<String>,
) -> Result<AddTablePageState, LoadError> { ) -> Result<AddTablePageState, LoadError> {
let authorization_request = let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
authenticated_request(headers, GetAuthorizationRequest {}).map_err(|_| LoadError::Unauthenticated)?; .map_err(|_| LoadError::Unauthenticated)?;
let mut auth = state.auth; let mut auth = state.auth;
let authorization = auth let authorization = auth
.get_authorization(authorization_request) .get_authorization(authorization_request)
@@ -38,10 +43,41 @@ pub(crate) async fn load_page(
.await .await
.map_err(|error| LoadError::Backend(error.message().to_string()))? .map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner(); .into_inner();
let effective_profile = draft.effective_profile_name();
match tree
.profiles
.iter()
.find(|profile| profile.name == effective_profile)
{
// An existing profile: its tables are the link targets, and their
// names are reserved against duplicate table creation.
Some(profile) => {
let table_names = profile
.tables
.iter()
.filter(|table| table.name != "accounts")
.map(|table| table.name.clone())
.collect::<Vec<_>>();
draft.existing_profile_tables = table_names.clone();
draft.set_available_relation_tables(table_names);
}
// A brand-new (or not-yet-named) profile has nothing to link to.
None => {
draft.existing_profile_tables.clear();
draft.links.clear();
}
}
Ok(AddTablePageState { Ok(AddTablePageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(), profiles: tree
form, .profiles
.into_iter()
.map(|profile| profile.name)
.collect(),
draft,
status,
error, error,
}) })
} }

View File

@@ -1,54 +1,85 @@
//! The builder's request handlers.
//!
//! Every interaction posts the whole draft and swaps the whole builder back,
//! so the server stays the single owner of the draft's rules — the same ones
//! the TUI client applies in-process between keystrokes.
use axum::{ use axum::{
Form,
extract::State, extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Form;
use crate::{AppState, services::authenticated_request}; use crate::{AppState, services::authenticated_request};
use super::{ use super::{
draft::TableDraft,
loader::{LoadError, load_page}, loader::{LoadError, load_page},
state::CreateTableForm, state::{AddTablePageState, BuilderForm},
ui, ui,
}; };
pub(crate) async fn new_table_page( /// GET /admin/tables/new
State(state): State<AppState>, pub(crate) async fn new_table_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
headers: HeaderMap, match load_page(state, &headers, TableDraft::new(), None, None).await {
) -> Response { Ok(page) => Html(ui::render_page(&page)).into_response(),
render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await) Err(error) => load_error_response(error),
}
} }
/// POST /admin/tables/builder — every button and every `change` in the builder.
pub(crate) async fn update_builder(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<BuilderForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut page = match load_page(state, &headers, form.to_draft(), None, None).await {
Ok(page) => page,
Err(error) => return load_error_response(error),
};
apply_action(&mut page, &form);
Html(ui::render_builder(&page)).into_response()
}
/// POST /admin/tables — create the table.
pub(crate) async fn create_table( pub(crate) async fn create_table(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<CreateTableForm>, Form(form): Form<BuilderForm>,
) -> Response { ) -> Response {
let submitted_form = form.clone(); if let Some(rejection) = reject_cross_site(&headers) {
if let Err(error) = load_page(state.clone(), &headers, form.clone(), None).await { return rejection;
return render_loaded(Err(error));
} }
if headers
.get("sec-fetch-site") let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await {
.is_some_and(|value| value == "cross-site") Ok(page) => page,
{ Err(error) => return load_error_response(error),
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); };
}
let request = match form.into_request() { // The draft is validated here with exactly the checks the client runs
// before it will save; the server re-validates authoritatively.
let request = match page.draft.clone().into_request() {
Ok(request) => request, Ok(request) => request,
Err(message) => { Err(message) => {
return render_loaded( page.error = Some(message);
load_page(state, &headers, submitted_form, Some(message)).await, return (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page)))
); .into_response();
} }
}; };
let profile_name = request.profile_name.clone(); let profile_name = request.profile_name.clone();
let mut definitions = state.definitions;
let request = match authenticated_request(&headers, request) { let request = match authenticated_request(&headers, request) {
Ok(request) => request, Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(), Err(_) => return Redirect::to("/login").into_response(),
}; };
let mut definitions = state.definitions;
match definitions.post_table_definition(request).await { match definitions.post_table_definition(request).await {
Ok(response) if response.get_ref().success => { Ok(response) if response.get_ref().success => {
let location = format!("/admin?profile={profile_name}"); let location = format!("/admin?profile={profile_name}");
@@ -56,40 +87,67 @@ pub(crate) async fn create_table(
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
}; };
let mut response = StatusCode::SEE_OTHER.into_response(); let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert(header::LOCATION, location.clone()); response
.headers_mut()
.insert(header::LOCATION, location.clone());
response.headers_mut().insert("hx-redirect", location); response.headers_mut().insert("hx-redirect", location);
response response
} }
Ok(response) => { Ok(response) => {
let detail = if response.get_ref().sql.is_empty() { page.error = Some(if response.get_ref().sql.is_empty() {
"The backend did not create the table.".to_string() "The backend did not create the table.".to_string()
} else { } else {
response.get_ref().sql.clone() response.get_ref().sql.clone()
}; });
( (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
StatusCode::UNPROCESSABLE_ENTITY, }
Html(ui::render_submission_error(&detail)), Err(error) => {
) page.error = Some(error.message().to_string());
.into_response() (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
} }
Err(error) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_submission_error(error.message())),
)
.into_response(),
} }
} }
fn render_loaded(result: Result<super::state::AddTablePageState, LoadError>) -> Response { /// Runs the pressed button against the draft.
match result { ///
Ok(page) => Html(ui::render_page(&page)).into_response(), /// `refresh` covers the plain re-renders — picking a profile or a column type
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), /// changes which fields apply, which is the client's field-visibility rule.
Err(LoadError::Forbidden) => ( fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
let index = form.index.unwrap_or(0);
match form.action.as_str() {
"add-column" => match page.draft.add_column_from_inputs() {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
"remove-column" => match page.draft.remove_column(index) {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
"toggle-index" => page.draft.toggle_column_indexed(index),
"cycle-link" => page.draft.cycle_link_mode(index),
"toggle-display" => page.draft.toggle_row_display_candidate(index),
_ => {}
}
}
fn reject_cross_site(headers: &HeaderMap) -> Option<Response> {
headers
.get("sec-fetch-site")
.is_some_and(|value| value == "cross-site")
.then(|| (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response())
}
fn load_error_response(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Html(ui::render_submission_error("Administrator access is required.")), Html(ui::render_submission_error(
"Administrator access is required.",
)),
) )
.into_response(), .into_response(),
Err(LoadError::Backend(message)) => ( LoadError::Backend(message) => (
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
Html(ui::render_submission_error(&message)), Html(ui::render_submission_error(&message)),
) )

View File

@@ -1,3 +1,4 @@
mod draft;
mod loader; mod loader;
mod logic; mod logic;
mod state; mod state;
@@ -13,5 +14,6 @@ use crate::AppState;
pub(crate) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/admin/tables/new", get(logic::new_table_page)) .route("/admin/tables/new", get(logic::new_table_page))
.route("/admin/tables/builder", post(logic::update_builder))
.route("/admin/tables", post(logic::create_table)) .route("/admin/tables", post(logic::create_table))
} }

View File

@@ -1,164 +1,293 @@
use crate::definitions::table_definition::{ //! Wire format for the builder form, and the page state the templates read.
ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink, //!
}; //! HTTP is stateless, so the whole draft travels with every interaction: each
//! already-added column, link and display column is posted back as a set of
//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`)
//! decodes the repeats into `Vec`s, which `to_draft` zips back into a
//! [`TableDraft`].
use super::draft::{ColumnDefinition, LinkDefinition, LinkMode, MoneyMode, TableDraft};
/// The `profile_name` option meaning "create a new profile too".
pub(crate) const NEW_PROFILE: &str = "__new__";
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct CreateTableForm { pub(crate) struct BuilderForm {
/// Which builder button was pressed. Empty on the initial page load.
#[serde(default)]
pub action: String,
/// Row the action applies to, for the per-row buttons.
#[serde(default)]
pub index: Option<usize>,
#[serde(default)] #[serde(default)]
pub profile_name: String, pub profile_name: String,
#[serde(default)] #[serde(default)]
pub profile_name_input: String,
#[serde(default)]
pub accounting_currency: String,
#[serde(default)]
pub table_name: String, pub table_name: String,
#[serde(default)] #[serde(default)]
pub columns: String,
#[serde(default)]
pub indexed_columns: String,
#[serde(default)]
pub required_links: String,
#[serde(default)]
pub optional_links: String,
#[serde(default)]
pub base_currency: String, pub base_currency: String,
// The pending column being described in the input panel.
#[serde(default)] #[serde(default)]
pub row_display_columns: String, pub column_name_input: String,
#[serde(default)]
pub column_type_input: String,
#[serde(default)]
pub temporal_type_input: String,
#[serde(default)]
pub gtin_type_input: String,
#[serde(default)]
pub column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
#[serde(default)]
pub column_rounding_input: String,
// One entry per already-added column, in order.
#[serde(default)]
pub column_names: Vec<String>,
#[serde(default)]
pub column_types: Vec<String>,
#[serde(default)]
pub column_indexed: Vec<String>,
#[serde(default)]
pub column_quantity_ledger: Vec<String>,
#[serde(default)]
pub column_rounding: Vec<String>,
// One entry per link target offered by the profile, in order.
#[serde(default)]
pub link_tables: Vec<String>,
#[serde(default)]
pub link_modes: Vec<String>,
#[serde(default)]
pub row_display_columns: Vec<String>,
} }
pub(crate) struct AddTablePageState { fn is_yes(value: &str) -> bool {
pub nav: crate::ui::Nav, value.trim().eq_ignore_ascii_case("yes")
pub profiles: Vec<String>,
pub form: CreateTableForm,
pub error: Option<String>,
} }
impl CreateTableForm { impl BuilderForm {
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> { pub(crate) fn creating_new_profile(&self) -> bool {
let profile_name = self.profile_name.trim().to_string(); self.profile_name == NEW_PROFILE
let table_name = self.table_name.trim().to_string(); }
if profile_name.is_empty() {
return Err("Select a profile.".to_string());
}
if table_name.is_empty() {
return Err("Enter a table name.".to_string());
}
let indexed_columns = comma_separated(&self.indexed_columns); /// Rebuilds the draft this form was rendered from.
let mut columns = Vec::new(); ///
let mut inline_indexes = Vec::new(); /// The column vectors are parallel, so a short one (a truncated or
for (index, line) in self.columns.lines().enumerate() { /// tampered-with post) simply limits how many columns are reconstructed
let line = line.trim(); /// rather than mis-pairing them.
if line.is_empty() { pub(crate) fn to_draft(&self) -> TableDraft {
continue; let creating_new_profile = self.creating_new_profile();
}
let mut parts = line.splitn(3, ':');
let name = parts.next().unwrap_or_default().trim();
let field_type = parts.next().unwrap_or_default().trim();
let flags = parts.next().unwrap_or_default();
if name.is_empty() || field_type.is_empty() {
return Err(format!(
"Column line {} must use `name: type`.",
index + 1
));
}
let flags = flags
.split(',')
.map(str::trim)
.filter(|flag| !flag.is_empty())
.collect::<Vec<_>>();
if flags.contains(&"indexed") {
inline_indexes.push(name.to_string());
}
let rounding = if flags.contains(&"half-up") {
MoneyRounding::HalfUp
} else {
MoneyRounding::None
};
columns.push(ColumnDefinition {
name: name.to_string(),
field_type: field_type.to_string(),
rounding: rounding.into(),
quantity_ledger: flags.contains(&"quantity-ledger"),
});
}
if columns.is_empty() {
return Err("Add at least one column.".to_string());
}
let mut indexes = indexed_columns; let column_count = [
for name in inline_indexes { self.column_names.len(),
if !indexes.contains(&name) { self.column_types.len(),
indexes.push(name); self.column_indexed.len(),
} self.column_quantity_ledger.len(),
} self.column_rounding.len(),
]
.into_iter()
.min()
.unwrap_or(0);
let mut links = comma_separated(&self.required_links) let columns = (0..column_count)
.into_iter() .map(|index| ColumnDefinition {
.map(|linked_table_name| TableLink { name: self.column_names[index].clone(),
linked_table_name, data_type: self.column_types[index].clone(),
required: true, indexed: is_yes(&self.column_indexed[index]),
quantity_ledger: is_yes(&self.column_quantity_ledger[index]),
money_mode: if self.column_rounding[index].trim() == MoneyMode::Rounded.label() {
MoneyMode::Rounded
} else {
MoneyMode::Exact
},
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
links.extend(
comma_separated(&self.optional_links)
.into_iter()
.map(|linked_table_name| TableLink {
linked_table_name,
required: false,
}),
);
let has_money = columns.iter().any(|column| { let link_count = self.link_tables.len().min(self.link_modes.len());
column.field_type.eq_ignore_ascii_case("money") let links = (0..link_count)
|| column.field_type.eq_ignore_ascii_case("accounting") .map(|index| LinkDefinition {
}); linked_table_name: self.link_tables[index].clone(),
let base_currency = self.base_currency.trim().to_ascii_uppercase(); mode: LinkMode::from_label(&self.link_modes[index]),
if has_money && base_currency.is_empty() { })
return Err("A base currency is required when a MONEY column is used.".to_string()); .collect();
}
Ok(PostTableDefinitionRequest { // Drop display columns whose column is gone, so a stale post cannot
accounting_currency: String::new(), // send a display column that no longer exists.
table_name, let row_display_columns = self
links, .row_display_columns
.iter()
.filter(|display| columns.iter().any(|column| &&column.name == display))
.cloned()
.collect();
TableDraft {
profile_name: if creating_new_profile {
String::new()
} else {
self.profile_name.trim().to_string()
},
profile_name_input: self.profile_name_input.clone(),
creating_new_profile,
accounting_currency: self.accounting_currency.clone(),
table_name: self.table_name.clone(),
base_currency: self.base_currency.clone(),
column_name_input: self.column_name_input.clone(),
column_type_input: self.column_type_input.clone(),
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
column_indexing_input: self.column_indexing_input.clone(),
column_quantity_ledger_input: self.column_quantity_ledger_input.clone(),
column_rounding_input: self.column_rounding_input.clone(),
columns, columns,
indexes, links,
profile_name, row_display_columns,
base_currency: if has_money { base_currency } else { String::new() }, // Filled in by the loader from the live profile tree, never by the
row_display_columns: comma_separated(&self.row_display_columns), // client: it is what duplicate table names are checked against.
}) existing_profile_tables: Vec::new(),
}
} }
} }
fn comma_separated(value: &str) -> Vec<String> { /// What the page and the builder fragment render.
value pub(crate) struct AddTablePageState {
.split(',') pub nav: crate::ui::Nav,
.map(str::trim) pub profiles: Vec<String>,
.filter(|value| !value.is_empty()) pub draft: TableDraft,
.map(str::to_string) /// The outcome of the last builder action, if any.
.collect() pub status: Option<String>,
pub error: Option<String>,
}
impl AddTablePageState {
/// The value the profile `<select>` should show as chosen.
pub(crate) fn selected_profile(&self) -> &str {
if self.draft.creating_new_profile {
NEW_PROFILE
} else {
&self.draft.profile_name
}
}
/// Row-display candidates: `id` first, then every column, matching the
/// client's candidate list.
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
let mut candidates = vec![RowDisplayCandidate {
index: 0,
name: "id".to_string(),
position: if self.draft.row_display_columns.is_empty() {
Some(0)
} else {
None
},
}];
candidates.extend(self.draft.columns.iter().enumerate().map(|(index, column)| {
RowDisplayCandidate {
index: index + 1,
name: column.name.clone(),
position: self.draft.row_display_position(&column.name),
}
}));
candidates
}
}
pub(crate) struct RowDisplayCandidate {
pub index: usize,
pub name: String,
/// `Some(0)` marks `id` as chosen; `Some(n)` is a column's 1-based place.
pub position: Option<usize>,
}
impl RowDisplayCandidate {
/// The selection mark, matching the client: a tick for `id`, otherwise the
/// column's place in the display order.
pub(crate) fn mark(&self) -> String {
match self.position {
Some(0) => "[x]".to_string(),
Some(position) => format!("[{position}]"),
None => "[ ]".to_string(),
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] fn posted_form() -> BuilderForm {
fn parses_columns_indexes_links_and_money_options() { BuilderForm {
let request = CreateTableForm { profile_name: "billing".into(),
profile_name: "accounting".into(),
table_name: "invoice".into(), table_name: "invoice".into(),
columns: "number: text:indexed\namount: money:half-up,quantity-ledger".into(), column_names: vec!["number".into(), "total".into()],
required_links: "customer".into(), column_types: vec!["text".into(), "money".into()],
base_currency: "eur".into(), column_indexed: vec!["yes".into(), "no".into()],
row_display_columns: "number, amount".into(), column_quantity_ledger: vec!["no".into(), "no".into()],
column_rounding: vec!["exact".into(), "half-up".into()],
link_tables: vec!["customer".into(), "project".into()],
link_modes: vec!["required".into(), "none".into()],
row_display_columns: vec!["number".into()],
..Default::default() ..Default::default()
} }
.into_request() }
.unwrap();
assert_eq!(request.indexes, vec!["number"]); #[test]
assert_eq!(request.links[0].linked_table_name, "customer"); fn round_trips_columns_links_and_display_columns() {
assert!(request.links[0].required); let draft = posted_form().to_draft();
assert_eq!(request.base_currency, "EUR");
assert_eq!(request.row_display_columns, vec!["number", "amount"]); assert_eq!(draft.columns.len(), 2);
assert!(request.columns[1].quantity_ledger); assert!(draft.columns[0].indexed);
assert_eq!(draft.columns[1].money_mode, MoneyMode::Rounded);
assert_eq!(draft.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[1].mode, LinkMode::None);
assert_eq!(draft.row_display_columns, vec!["number"]);
assert!(!draft.creating_new_profile);
}
#[test]
fn the_new_profile_option_switches_to_the_typed_name() {
let mut form = posted_form();
form.profile_name = NEW_PROFILE.to_string();
form.profile_name_input = " bookkeeping ".to_string();
let draft = form.to_draft();
assert!(draft.creating_new_profile);
assert_eq!(draft.profile_name, "");
assert_eq!(draft.effective_profile_name(), "bookkeeping");
}
#[test]
fn mismatched_column_vectors_never_mis_pair() {
let mut form = posted_form();
form.column_types.pop();
let draft = form.to_draft();
assert_eq!(draft.columns.len(), 1);
assert_eq!(draft.columns[0].name, "number");
assert_eq!(draft.columns[0].data_type, "text");
}
#[test]
fn display_columns_for_removed_columns_are_dropped() {
let mut form = posted_form();
form.row_display_columns = vec!["number".into(), "gone".into()];
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
}
#[test]
fn the_draft_never_trusts_the_posted_table_list() {
// `existing_profile_tables` is what duplicate-name checks read, so it
// must come from the server, not the form.
assert!(posted_form().to_draft().existing_profile_tables.is_empty());
} }
} }

View File

@@ -2,24 +2,154 @@ use askama::Template;
use crate::ui::{Alert, Nav, render}; use crate::ui::{Alert, Nav, render};
use super::state::AddTablePageState; use super::{
draft::{COLUMN_TYPES, CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
state::AddTablePageState,
};
/// GET /admin/tables/new /// GET /admin/tables/new — the page shell around the builder.
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/add_table/add_table.html")] #[template(path = "pages/add_table/add_table.html")]
struct AddTablePage<'a> { struct AddTablePage<'a> {
nav: Nav, nav: Nav,
page: &'a AddTablePageState, page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
currency_codes: &'static [&'static str],
}
/// POST /admin/tables/builder — the `#builder` swap, which is the same markup
/// the page embeds, so one template serves both.
#[derive(Template)]
#[template(path = "pages/add_table/builder.html")]
struct BuilderFragment<'a> {
page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
} }
pub(crate) fn render_page(page: &AddTablePageState) -> String { pub(crate) fn render_page(page: &AddTablePageState) -> String {
render(&AddTablePage { render(&AddTablePage {
nav: page.nav.clone(), nav: page.nav.clone(),
page, page,
column_types: COLUMN_TYPES,
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
currency_codes: CURRENCY_CODES,
}) })
} }
/// POST /admin/tables — the #submission-status swap. pub(crate) fn render_builder(page: &AddTablePageState) -> String {
render(&BuilderFragment {
page,
column_types: COLUMN_TYPES,
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
})
}
/// Used when the page itself cannot be loaded (auth or backend failure).
pub(crate) fn render_submission_error(message: &str) -> String { pub(crate) fn render_submission_error(message: &str) -> String {
render(&Alert::error("Could not create the table", message)) render(&Alert::error("Could not create the table", message))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::add_table::draft::{ColumnDefinition, LinkMode, MoneyMode, TableDraft};
fn page() -> AddTablePageState {
let mut draft = TableDraft::new();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.push(ColumnDefinition {
name: "number".to_string(),
data_type: "text".to_string(),
indexed: true,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
});
draft.set_available_relation_tables(vec!["customer".to_string()]);
draft.cycle_link_mode(0);
draft.toggle_row_display_candidate(1);
AddTablePageState {
nav: Nav::default(),
profiles: vec!["billing".to_string()],
draft,
status: None,
error: None,
}
}
/// `render` turns a template failure into an error string rather than
/// panicking, so the markup has to be asserted on.
#[test]
fn the_builder_carries_the_whole_draft_and_the_preview() {
let html = render_builder(&page());
assert!(!html.contains("Template error"), "{html}");
// Every part of the draft travels with the next request.
assert!(html.contains(r#"name="column_names" value="number""#));
assert!(html.contains(r#"name="column_indexed" value="yes""#));
assert!(html.contains(r#"name="link_tables" value="customer""#));
assert!(html.contains(r#"name="link_modes" value="optional""#));
assert!(html.contains(r#"name="row_display_columns" value="number""#));
// The preview shows the schema as it will exist.
assert!(html.contains("customer_id"));
assert!(html.contains("BIGSERIAL"));
assert!(html.contains("TIMESTAMPTZ"));
}
#[test]
fn the_page_offers_the_new_profile_option_and_the_currency_list() {
let html = render_page(&page());
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"value="__new__""#));
assert!(html.contains(r#"<datalist id="currency-codes">"#));
assert!(html.contains(r#"<option value="EUR">"#));
}
#[test]
fn conditional_fields_follow_the_pending_column_type() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="temporal_type_input""#));
state.draft.column_type_input = "temporal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="temporal_type_input""#));
assert!(!html.contains(r#"name="gtin_type_input""#));
state.draft.column_type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Money reveals rounding, and the base currency becomes editable.
state.draft.column_type_input = "money".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="column_rounding_input""#));
assert!(html.contains(r#"list="currency-codes""#));
}
#[test]
fn the_new_profile_fields_appear_only_for_a_new_profile() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"name="profile_name_input" value"#));
state.draft.creating_new_profile = true;
let html = render_builder(&state);
assert!(html.contains(r#"name="profile_name_input""#));
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
}
#[test]
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
let html = render_builder(&page());
assert!(html.contains(r#""action": "cycle-link", "index": "0""#));
assert!(html.contains("mode-optional"));
assert_eq!(LinkMode::Optional.label(), "optional");
}
}

View File

@@ -124,6 +124,36 @@ table { width: 100%; border-collapse: collapse; background: white; }
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; } th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
th { position: sticky; top: 0; background: #f9fafb; } th { position: sticky; top: 0; background: #f9fafb; }
/* ---------- Table builder (Add table) ---------- */
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
.builder-section > button.secondary:hover { background: #e9edf4; }
.builder-table { margin-top: 14px; }
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
.builder-table td { font-size: 13px; }
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
.builder-table.preview .source-relation td { color: #3c5a86; }
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
button.toggle:hover { background: #e9edf4; }
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
button.danger:hover { background: #f8e5e5; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
.tag + .tag { margin-left: 4px; }
/* ---------- Narrow screens ---------- */ /* ---------- Narrow screens ---------- */
@media (max-width: 850px) { @media (max-width: 850px) {

View File

@@ -1,42 +1,24 @@
{# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #} {# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #}
{% extends "ui/form_page.html" %} {% extends "ui/form_page.html" %}
{% import "ui/alert.html" as alert %}
{% block title %}Add table{% endblock %} {% block title %}Add table{% endblock %}
{% block eyebrow %}Table definition{% endblock %} {% block eyebrow %}Table definition{% endblock %}
{% block heading %}Add table{% endblock %} {% block heading %}Add table{% endblock %}
{% block lead %}<p>Create a table through the existing gRPC table-definition service.</p>{% endblock %} {% block lead %}<p>Build the table one column at a time, the same way the terminal client does.</p>{% endblock %}
{% block form %} {% block form %}
<form hx-post="/admin/tables" hx-target="#submission-status" hx-swap="innerHTML" {#
One form holds the whole draft. `#builder` is what every interaction swaps,
so the form element itself — and therefore the submit target — survives.
#}
<form id="table-form" hx-post="/admin/tables" hx-target="#builder" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]"> hx-disabled-elt="button[type=submit]">
<div class="form-grid"> <div id="builder" aria-live="polite">
<label>Profile {% include "pages/add_table/builder.html" %}
<select name="profile_name" required>
<option value="">Choose a profile</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.form.profile_name == *profile %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
</select>
</label>
<label>Table name<input name="table_name" value="{{ page.form.table_name }}" required placeholder="invoices"></label>
<label class="wide">Columns
<textarea name="columns" rows="9" required
placeholder="number: text:indexed&#10;issued_on: date&#10;stock: int:quantity-ledger">{{ page.form.columns }}</textarea>
<small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small>
</label>
<label>Additional indexed columns<input name="indexed_columns" value="{{ page.form.indexed_columns }}" placeholder="number, issued_on"></label>
<label>Base currency<input name="base_currency" value="{{ page.form.base_currency }}" maxlength="3" placeholder="EUR"></label>
<label>Required links<input name="required_links" value="{{ page.form.required_links }}" placeholder="customer, address"></label>
<label>Optional links<input name="optional_links" value="{{ page.form.optional_links }}" placeholder="project"></label>
<label>Row display columns<input name="row_display_columns" value="{{ page.form.row_display_columns }}" placeholder="name, ico"></label>
</div>
<div id="submission-status" aria-live="polite">
{%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%}
</div>
<div class="form-actions">
<a href="/admin">Cancel</a>
<button type="submit">Create table</button>
</div> </div>
</form> </form>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,237 @@
{#
POST /admin/tables/builder — crate::pages::add_table::ui::BuilderFragment.
The whole builder, which is also what `add_table.html` embeds on first load.
Every control posts the entire draft back to /admin/tables/builder and swaps
this markup in again, so the rules live on the server exactly as they do in
the TUI client. The hidden inputs in the columns, relations and display
sections are what carry the draft across requests.
#}
{% import "ui/alert.html" as alert %}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%}
<section class="builder-section">
<h2>Table</h2>
<div class="form-grid">
<label>Profile
<select name="profile_name" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">Choose a profile</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selected_profile() == profile.as_str() %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
<option value="__new__" {% if page.draft.creating_new_profile %}selected{% endif %}>+ New profile…</option>
</select>
</label>
{% if page.draft.show_profile_name_input() %}
<label>New profile name
<input name="profile_name_input" value="{{ page.draft.profile_name_input }}" placeholder="bookkeeping">
</label>
{% endif %}
{% if page.draft.show_accounting_currency() %}
<label>Accounting currency
<input name="accounting_currency" value="{{ page.draft.accounting_currency }}" list="currency-codes"
maxlength="3" placeholder="EUR">
</label>
{% else %}
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
{% endif %}
<label>Table name
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice"
hx-post="/admin/tables/builder" hx-trigger="change" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "refresh"}'>
</label>
{% if page.draft.show_base_currency() %}
<label>Base currency
<input name="base_currency" value="{{ page.draft.base_currency }}" list="currency-codes"
maxlength="3" placeholder="EUR">
<small>Required while the table has a MONEY or ACCOUNTING column.</small>
</label>
{% else %}
<input type="hidden" name="base_currency" value="{{ page.draft.base_currency }}">
{% endif %}
</div>
</section>
<section class="builder-section">
<h2>Add a column</h2>
<div class="form-grid">
<label>Column name
<input name="column_name_input" value="{{ page.draft.column_name_input }}" placeholder="number">
</label>
<label>Column type
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
<option value="">Choose a type</option>
{% for column_type in column_types %}
<option value="{{ column_type }}" {% if page.draft.column_type_input == *column_type %}selected{% endif %}>{{ column_type }}</option>
{% endfor %}
</select>
</label>
{% if page.draft.show_temporal_type() %}
<label>Temporal type
<select name="temporal_type_input">
<option value="">Choose a temporal type</option>
{% for temporal_type in temporal_types %}
<option value="{{ temporal_type }}" {% if page.draft.temporal_type_input == *temporal_type %}selected{% endif %}>{{ temporal_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.show_gtin_type() %}
<label>GTIN type
<select name="gtin_type_input">
<option value="">Choose a GTIN length</option>
{% for gtin_type in gtin_types %}
<option value="{{ gtin_type }}" {% if page.draft.gtin_type_input == *gtin_type %}selected{% endif %}>GTIN-{{ gtin_type }}</option>
{% endfor %}
</select>
</label>
{% endif %}
{% if page.draft.show_rounding() %}
<label>Rounding
<select name="column_rounding_input">
<option value="none" {% if page.draft.column_rounding_input != "half-up" %}selected{% endif %}>none</option>
<option value="half-up" {% if page.draft.column_rounding_input == "half-up" %}selected{% endif %}>half-up</option>
</select>
</label>
{% endif %}
<label>Indexing
<select name="column_indexing_input">
<option value="no" {% if page.draft.column_indexing_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.column_indexing_input == "yes" %}selected{% endif %}>yes</option>
</select>
</label>
<label>Quantity ledger
<select name="column_quantity_ledger_input">
<option value="no" {% if page.draft.column_quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
<option value="yes" {% if page.draft.column_quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
</select>
<small>INT, BIGINT, DECIMAL or MONEY only.</small>
</label>
</div>
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>Add column</button>
</section>
<section class="builder-section">
<h2>Columns <span class="count">{{ page.draft.columns.len() }}</span></h2>
{% if page.draft.columns.is_empty() %}
<p class="hint">No columns yet. Describe one above and press <em>Add column</em>.</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Name</th><th>Type</th><th>Indexed</th><th>Options</th><th></th></tr></thead>
<tbody>
{% for column in page.draft.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.data_type }}</td>
<td>
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
</button>
</td>
<td>
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
{% if !column.option_label().is_empty() %}<span class="tag">{{ column.option_label() }}</span>{% endif %}
</td>
<td>
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# The draft itself: one set of fields per column, in order. #}
{% for column in page.draft.columns %}
<input type="hidden" name="column_names" value="{{ column.name }}">
<input type="hidden" name="column_types" value="{{ column.data_type }}">
<input type="hidden" name="column_indexed" value="{% if column.indexed %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_quantity_ledger" value="{% if column.quantity_ledger %}yes{% else %}no{% endif %}">
<input type="hidden" name="column_rounding" value="{{ column.money_mode.label() }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>Relations</h2>
{% if page.draft.links.is_empty() %}
<p class="hint">No other tables in this profile to link to.</p>
{% else %}
<p class="hint">Each active relation adds a <code>&lt;table&gt;_id</code> column.</p>
<ul class="builder-list">
{% for link in page.draft.links %}
<li>
<button type="button" class="toggle mode-{{ link.mode.label() }}" hx-post="/admin/tables/builder"
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "cycle-link", "index": "{{ loop.index0 }}"}'>{{ link.mode.label() }}</button>
<code>{{ link.linked_table_name }}</code>
</li>
{% endfor %}
</ul>
{% endif %}
{% for link in page.draft.links %}
<input type="hidden" name="link_tables" value="{{ link.linked_table_name }}">
<input type="hidden" name="link_modes" value="{{ link.mode.label() }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>Row display columns</h2>
<p class="hint">What identifies a row to users, in the order chosen. Pick <code>id</code> to clear the list.</p>
<ul class="builder-list">
{% for candidate in page.row_display_candidates() %}
<li>
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
hx-target="#builder" hx-swap="innerHTML"
hx-vals='{"action": "toggle-display", "index": "{{ candidate.index }}"}'>{{ candidate.mark() }}</button>
<code>{{ candidate.name }}</code>
</li>
{% endfor %}
</ul>
{% for display in page.draft.row_display_columns %}
<input type="hidden" name="row_display_columns" value="{{ display }}">
{% endfor %}
</section>
<section class="builder-section">
<h2>Table definition preview</h2>
<table class="builder-table preview">
<thead><tr><th></th><th>Column</th><th>Type</th><th>Options</th><th>Source</th></tr></thead>
<tbody>
{% for row in page.draft.preview_rows() %}
<tr class="source-{{ row.source }}">
<td class="mark">{{ row.mark }}</td>
<td><code>{{ row.column }}</code></td>
<td>{{ row.data_type }}</td>
<td>{{ row.option }}</td>
<td>{{ row.source }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<div class="form-actions">
<a href="/admin">Cancel</a>
<button type="submit">Create table</button>
</div>