diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 37342892..b443afc5 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -458,6 +458,31 @@ message ListColumnTypesResponse { // "temporal" for the date and time types, "gtin" for the GTIN lengths. // Empty when the type stands on its own. string group = 9; + + // One column a compound type expands into. + message GeneratedColumn { + // Name the server gives the column. Fixed, and reserved: a table + // declaring a compound type may not also declare a column of this name. + string name = 1; + + // The generated column's own logical type, as this same catalog + // describes it. + string field_type = 2; + + // Whether the generated column carries the currency and rounding + // declared on the definition row. + bool inherits_currency = 3; + } + + // What this type expands into, in the order the server creates the + // columns. Populated for compound types only, where the generated names + // are fixed; empty for every other type, including the ones whose + // companions are named after the declared column (phone, iban). + // + // A picker offering a compound type shows these, so choosing it is not a + // blind choice. The columns stay the server's to create — none of them may + // be declared. + repeated GeneratedColumn generated_columns = 10; } // Every column type, declarable or not, ordered by name. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index b5beab0d..f404c4dc 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index cd1b0a52..e5a680fa 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -431,7 +431,7 @@ pub struct ListColumnTypesResponse { /// Nested message and enum types in `ListColumnTypesResponse`. pub mod list_column_types_response { /// One column type and everything a client needs to know to offer it. - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + #[derive(Clone, PartialEq, ::prost::Message)] pub struct ColumnType { /// Logical column type (e.g. "money", "instant"). Passed to the server as /// ColumnDefinition.field_type — see `spelling`, which is what says whether @@ -474,6 +474,35 @@ pub mod list_column_types_response { /// Empty when the type stands on its own. #[prost(string, tag = "9")] pub group: ::prost::alloc::string::String, + /// What this type expands into, in the order the server creates the + /// columns. Populated for compound types only, where the generated names + /// are fixed; empty for every other type, including the ones whose + /// companions are named after the declared column (phone, iban). + /// + /// A picker offering a compound type shows these, so choosing it is not a + /// blind choice. The columns stay the server's to create — none of them may + /// be declared. + #[prost(message, repeated, tag = "10")] + pub generated_columns: ::prost::alloc::vec::Vec, + } + /// Nested message and enum types in `ColumnType`. + pub mod column_type { + /// One column a compound type expands into. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct GeneratedColumn { + /// Name the server gives the column. Fixed, and reserved: a table + /// declaring a compound type may not also declare a column of this name. + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + /// The generated column's own logical type, as this same catalog + /// describes it. + #[prost(string, tag = "2")] + pub field_type: ::prost::alloc::string::String, + /// Whether the generated column carries the currency and rounding + /// declared on the definition row. + #[prost(bool, tag = "3")] + pub inherits_currency: bool, + } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/server b/server index 22dcc2e0..195db2f0 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 22dcc2e032e53e879deca0eddf2d46dc224ec99d +Subproject commit 195db2f04a8bb514e3a73bb112ecea486da5e08d diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 2af5002f..a2f386d7 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -18,6 +18,11 @@ use crate::{ schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, }; +/// The compound type that also brings a system column with it. Which columns +/// it generates is the catalog's answer; the account foreign key is not one of +/// them, and [`TableDraft::preview_rows`] is where that is explained. +pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting"; + /// One row of the "Table definition preview" — the schema as it will exist. pub(crate) struct PreviewRow { pub mark: String, @@ -95,6 +100,15 @@ impl TableDraft { Ok(format!("Column `{}` removed.", removed.name)) } + /// Moves one column one place up or down the list. + /// + /// The display columns keep their own order, which is the order they were + /// chosen in rather than the order the columns are declared in, so nothing + /// here touches them. + pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option { + self.columns.move_column(index, offset) + } + /// Adds or removes one display-column candidate. /// /// Index 0 is `id`, which is not a display column of its own: choosing it @@ -238,7 +252,7 @@ impl TableDraft { }, ]; - for column in &self.columns.added { + for (index, column) in self.columns.added.iter().enumerate() { rows.push(PreviewRow { mark: self .row_display_position(&column.name) @@ -249,6 +263,38 @@ impl TableDraft { option: column.option_label(), source: "user".to_string(), }); + + // A compound column is a definition row: the columns the table + // really gets are the ones it expands into, created where the + // definition row sits. They are listed here so what a compound + // choice does is visible before the table exists. + for generated in self.columns.generated_columns_of(index) { + rows.push(PreviewRow { + mark: String::new(), + column: generated.name.clone(), + data_type: generated.data_type.clone(), + option: if generated.inherits_currency { + format!("{}, {}", column.currency, column.money_mode.label()) + } else { + String::new() + }, + source: "generated".to_string(), + }); + } + + // The one companion the catalog does not report, because it is a + // system column rather than a user one: ACCOUNTING's foreign key to + // the profile's accounts, which the data API exposes as the virtual + // `account` field. + if column.data_type == ACCOUNTING_FIELD_TYPE { + rows.push(PreviewRow { + mark: String::new(), + column: "account_id".to_string(), + data_type: "BIGINT".to_string(), + option: "not null, → accounts, written as account".to_string(), + source: "system".to_string(), + }); + } } rows.push(PreviewRow { @@ -471,6 +517,88 @@ mod tests { assert_eq!(rows[0].mark, "[x]"); } + /// Moving a column changes the order the table is declared in and nothing + /// else — the display columns are shown in the order they were chosen in, + /// which is not this order. + #[test] + fn moving_a_column_leaves_the_display_columns_alone() { + let mut draft = draft_with_column("number", "text"); + draft.columns.added.push(ColumnDefinition { + name: "issued_on".to_string(), + data_type: "date".to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + draft.toggle_row_display_candidate(1); // number + draft.toggle_row_display_candidate(2); // issued_on + + draft.move_column(0, 1).unwrap(); + + assert_eq!( + draft + .columns + .added + .iter() + .map(|column| column.name.as_str()) + .collect::>(), + ["issued_on", "number"] + ); + assert_eq!(draft.row_display_columns, vec!["number", "issued_on"]); + } + + /// The point of the preview: a definition row is not one column, and the + /// columns it stands for are the ones the table will really have. + #[test] + fn the_preview_expands_an_accounting_column() { + let mut draft = draft_with_column("number", "text"); + draft.columns.added.push(ColumnDefinition { + name: "accounting".to_string(), + data_type: "accounting".to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Rounded, + currency: "CZK".to_string(), + }); + + let rows = draft.preview_rows(); + let columns = rows + .iter() + .map(|row| row.column.as_str()) + .collect::>(); + + assert_eq!( + columns, + vec![ + "id", + "deleted", + "number", + "accounting", + "name", + "tax_point_date", + "debit", + "credit", + // The account foreign key, which the catalog does not report + // because it is a system column rather than a generated one. + "account_id", + "created_at", + ] + ); + + let row = |name: &str| { + rows.iter() + .find(|row| row.column == name) + .unwrap_or_else(|| panic!("`{name}` is missing from the preview")) + }; + assert_eq!(row("name").source, "generated"); + assert_eq!(row("tax_point_date").data_type, "date"); + // DEBIT and CREDIT are kept in the definition row's own currency. + assert_eq!(row("debit").option, "CZK, half-up"); + assert_eq!(row("name").option, ""); + assert_eq!(row("account_id").source, "system"); + } + #[test] fn indexed_columns_become_the_index_list() { let mut draft = draft_with_column("number", "text"); diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index c10aa8e3..241525b8 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -152,6 +152,10 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, + // The order columns are declared in is the order the table gets them + // in, so moving one is a change to the draft like any other. + "move-column-up" => page.status = page.draft.move_column(index, -1), + "move-column-down" => page.status = page.draft.move_column(index, 1), "toggle-index" => page.draft.columns.toggle_indexed(index), "toggle-display" => page.draft.toggle_row_display_candidate(index), _ => {} diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index c7131b7e..cedd9dc8 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -13,7 +13,7 @@ use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows}; -use super::draft::TableDraft; +use super::draft::{ACCOUNTING_FIELD_TYPE, TableDraft}; /// The `profile_name` option meaning "create a new profile too". pub(crate) const NEW_PROFILE: &str = "__new__"; @@ -172,6 +172,75 @@ impl AddTablePageState { } } + /// The "Columns" list: every declared column, each followed by the columns + /// it expands into. + /// + /// The expansion is flattened here rather than nested in the template so + /// the markup stays one loop over one list, and so what a definition row + /// brings is decided in one place. + pub(crate) fn column_rows(&self) -> Vec { + let columns = &self.draft.columns; + let last_index = columns.added.len().saturating_sub(1); + let mut rows = Vec::new(); + + for (index, column) in columns.added.iter().enumerate() { + let mut tags = Vec::new(); + if column.quantity_ledger { + tags.push("quantity ledger".to_string()); + } + if !column.option_label().is_empty() { + tags.push(column.option_label()); + } + rows.push(ColumnRow { + index: Some(index), + first: index == 0, + last: index == last_index, + name: column.name.clone(), + data_type: column.data_type.clone(), + indexable: columns.is_indexable(index), + indexed: column.indexed, + tags, + }); + + for generated in columns.generated_columns_of(index) { + let mut tags = vec![format!("generated by {}", column.data_type)]; + if generated.inherits_currency { + tags.push(format!("{}, {}", column.currency, column.money_mode.label())); + } + rows.push(ColumnRow { + index: None, + first: false, + last: false, + name: generated.name.clone(), + data_type: generated.data_type.clone(), + indexable: false, + indexed: false, + tags, + }); + } + + // The account foreign key is a system column rather than a + // generated user column, so the catalog does not report it; the + // same explanation as in `TableDraft::preview_rows`. + if column.data_type == ACCOUNTING_FIELD_TYPE { + rows.push(ColumnRow { + index: None, + first: false, + last: false, + name: "account_id".to_string(), + data_type: "BIGINT".to_string(), + indexable: false, + indexed: true, + tags: vec![ + "system column".to_string(), + "written as account".to_string(), + ], + }); + } + } + rows + } + /// Row-display candidates: `id` first, then every column, matching the /// client's candidate list. pub(crate) fn row_display_candidates(&self) -> Vec { @@ -203,6 +272,23 @@ impl AddTablePageState { } } +/// One line of the "Columns" list: either a column the user declared, or one +/// the server will generate from the definition row above it. +pub(crate) struct ColumnRow { + /// Where the column sits in the draft, for the buttons that act on it. + /// `None` for a generated column, which is not the user's to act on: it + /// moves and is removed with the definition row it came from. + pub index: Option, + /// Whether it can move any further up, and any further down. + pub first: bool, + pub last: bool, + pub name: String, + pub data_type: String, + pub indexable: bool, + pub indexed: bool, + pub tags: Vec, +} + pub(crate) struct RowDisplayCandidate { pub index: usize, pub name: String, @@ -288,6 +374,57 @@ mod tests { assert_eq!(form.to_draft().row_display_columns, vec!["number"]); } + /// The column list shows a definition row with the columns it expands + /// into. Only the definition row is the user's to act on: the generated + /// ones carry no index, so they get no buttons. + #[test] + fn the_column_list_shows_what_a_definition_row_expands_into() { + let mut page = AddTablePageState { + nav: crate::ui::Nav::default(), + profiles: Vec::new(), + draft: posted_form().to_draft(), + status: None, + error: None, + }; + page.draft.columns.catalog = crate::schema::tests::catalog(); + page.draft.columns.added.push(crate::schema::ColumnDefinition { + name: "accounting".to_string(), + data_type: "accounting".to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + currency: "EUR".to_string(), + }); + + let rows = page.column_rows(); + assert_eq!( + rows.iter().map(|row| row.name.as_str()).collect::>(), + [ + "number", + "total", + "accounting", + "name", + "tax_point_date", + "debit", + "credit", + "account_id", + ] + ); + + let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap(); + // Only the three declared columns can be moved, indexed or removed. + assert_eq!( + rows.iter().filter(|row| row.index.is_some()).count(), + 3, + "only declared columns carry an index" + ); + assert!(row("number").first); + assert!(row("accounting").last); + assert!(!row("accounting").indexable, "a definition row is no column"); + assert!(row("debit").tags.contains(&"EUR, exact".to_string())); + assert!(row("name").tags.contains(&"generated by accounting".to_string())); + } + #[test] fn the_draft_never_trusts_the_posted_table_list() { // `existing_profile_tables` is what duplicate-name checks read, so it diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index c854d422..976e605c 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -197,6 +197,77 @@ mod tests { assert!(html.contains(r#""#)); } + /// Choosing a compound type says what it will add, instead of leaving the + /// choice to be discovered after the table exists. + #[test] + fn choosing_accounting_shows_the_columns_it_will_generate() { + let mut state = page(); + assert!(!render_builder(&state).contains("compound-note")); + + state.draft.columns.type_input = "accounting".to_string(); + let html = render_builder(&state); + + assert!(html.contains("compound-note")); + for generated in ["name", "tax_point_date", "debit", "credit", "account_id"] { + assert!( + html.contains(&format!("{generated}")), + "the note is missing {generated}" + ); + } + // A definition row is named after its own type, so there is nothing to + // type in and nothing to index. + assert!(!html.contains(r#"name="column_name_input""#)); + assert!(!html.contains(r#"name="column_indexing_input""#)); + // It does declare the currency its DEBIT and CREDIT are kept in. + assert!(html.contains(r#"name="column_currency_input""#)); + } + + /// The column list can be reordered, and the ends say so by refusing. + #[test] + fn columns_carry_move_buttons_that_stop_at_the_ends() { + let mut state = page(); + state.draft.columns.added.push(ColumnDefinition { + name: "total".to_string(), + data_type: "money".to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + currency: "EUR".to_string(), + }); + + let html = render_builder(&state); + + assert!(html.contains(r#""action": "move-column-down", "index": "0""#)); + assert!(html.contains(r#""action": "move-column-up", "index": "1""#)); + // The first column cannot move up, nor the last one down. + assert!(!html.contains(r#""action": "move-column-up", "index": "0""#)); + assert!(!html.contains(r#""action": "move-column-down", "index": "1""#)); + assert_eq!(html.matches("disabled").count(), 2); + } + + /// And an added definition row is listed with the columns it stands for. + #[test] + fn the_column_list_shows_generated_columns_under_their_definition_row() { + let mut state = page(); + state.draft.columns.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(), + }); + + let html = render_builder(&state); + + assert!(html.contains("generated-row")); + assert!(html.contains("generated by accounting")); + assert!(html.contains("tax_point_date")); + // A generated column is not the user's to remove: the definition row + // is the only one of the two with a Remove button. + assert_eq!(html.matches("remove-column").count(), 2); + } + #[test] fn the_new_profile_fields_appear_only_for_a_new_profile() { let mut state = page(); diff --git a/web/src/schema/mod.rs b/web/src/schema/mod.rs index 82a7f2f1..7bf36dcf 100644 --- a/web/src/schema/mod.rs +++ b/web/src/schema/mod.rs @@ -76,6 +76,19 @@ pub(crate) struct ColumnType { /// Groups several types behind one choice in the picker; empty when the /// type stands on its own. pub group: String, + /// What a compound type expands into, in the order the server creates the + /// columns. Empty for every other type. + pub generated_columns: Vec, +} + +/// One column a compound type expands into, as the backend describes it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GeneratedColumn { + pub name: String, + pub data_type: String, + /// The column is kept in the currency and rounding declared on the + /// definition row. + pub inherits_currency: bool, } /// Every column type the backend accepts, as one screen's picker reads it. @@ -184,6 +197,15 @@ impl ColumnCatalog { .is_some_and(|column_type| column_type.compound) } + /// The columns a compound type expands into, so a screen can show what + /// choosing it will really add. The names are the server's, not this + /// crate's: it reports them with the type, and they are fixed. + pub(crate) fn generated_columns(&self, field_type: &str) -> &[GeneratedColumn] { + self.find(field_type) + .map(|column_type| column_type.generated_columns.as_slice()) + .unwrap_or_default() + } + fn is_creation_only(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.creation_only) @@ -455,6 +477,23 @@ impl ColumnDraft { self.pending_carries_currency() } + /// A compound column is named after its type, so there is nothing to type + /// in: the panel shows what it will generate instead of a name field. + pub(crate) fn pending_is_compound(&self) -> bool { + self.catalog.is_compound(&self.type_input) + } + + /// What the pending choice would add to the table. Empty unless the choice + /// is a compound type. + pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] { + self.catalog.generated_columns(&self.type_input) + } + + /// The name a compound definition row takes, which is its own type. + pub(crate) fn pending_compound_name(&self) -> String { + self.type_input.trim().to_ascii_lowercase() + } + /// The group the pending choice names, when it names one rather than a /// type — which is what asks for a follow-up field. fn pending_group(&self) -> Option { @@ -599,6 +638,39 @@ impl ColumnDraft { Ok(self.added.remove(index)) } + /// Moves one column one place towards the front or the back of the list. + /// + /// The order is the order the columns are declared in, which is the order + /// the table gets them in — including a compound column, whose generated + /// companions are created where its definition row sits. Moving the + /// definition row therefore moves the whole block it expands into. + /// + /// A move off either end is not an error: the buttons for it are not + /// rendered, and a crafted post asking for one leaves the order alone. + pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option { + let target = index.checked_add_signed(offset)?; + if index >= self.added.len() || target >= self.added.len() { + return None; + } + self.added.swap(index, target); + Some(format!( + "Column `{}` moved {}.", + self.added[target].name, + if offset < 0 { "up" } else { "down" } + )) + } + + /// What an already-added column expands into — empty unless it is a + /// compound one. This is what the column list shows underneath it, so the + /// columns a definition row brings are visible while the table is still + /// being described. + pub(crate) fn generated_columns_of(&self, index: usize) -> &[GeneratedColumn] { + self.added + .get(index) + .map(|column| self.catalog.generated_columns(&column.data_type)) + .unwrap_or_default() + } + /// Whether a column can be indexed or identify a row. A compound column /// leaves no column of its own name behind, so it can do neither. pub(crate) fn is_indexable(&self, index: usize) -> bool { @@ -791,6 +863,15 @@ pub(crate) fn column_catalog(column_types: Vec) -> ColumnCatalo creation_only: column_type.creation_only, allows_quantity_ledger: column_type.allows_quantity_ledger, group: column_type.group, + generated_columns: column_type + .generated_columns + .into_iter() + .map(|generated| GeneratedColumn { + name: generated.name, + data_type: generated.field_type, + inherits_currency: generated.inherits_currency, + }) + .collect(), }) .collect(), ) @@ -954,6 +1035,15 @@ pub(crate) mod tests { creation_only: false, allows_quantity_ledger: false, group: String::new(), + generated_columns: Vec::new(), + } + } + + fn generated_column(name: &str, data_type: &str, inherits_currency: bool) -> GeneratedColumn { + GeneratedColumn { + name: name.to_string(), + data_type: data_type.to_string(), + inherits_currency, } } @@ -987,9 +1077,23 @@ pub(crate) mod tests { ColumnCatalog::new(vec![ ColumnType { requires_currency: true, + // The companions the server reports with the type, in the + // order it creates them. + generated_columns: vec![ + generated_column("name", "text", false), + generated_column("tax_point_date", "date", false), + generated_column("debit", "money", true), + generated_column("credit", "money", true), + ], ..compound("accounting") }, - compound("accounting_transfer"), + ColumnType { + generated_columns: vec![ + generated_column("source_period_id", "bigint", false), + generated_column("target_period_id", "bigint", false), + ], + ..compound("accounting_transfer") + }, ColumnType { sql_type: "TIMESTAMPTZ(0)".to_string(), ..grouped("instant", "temporal") @@ -1394,6 +1498,78 @@ pub(crate) mod tests { assert!(columns[0].indexed); } + /// The declared order is the order the table gets its columns in, so it is + /// the user's to change — and a move off either end changes nothing rather + /// than wrapping around or panicking. + #[test] + fn a_column_moves_one_place_and_stops_at_the_ends() { + let mut draft = draft(); + for name in ["number", "issued_on", "total"] { + draft.name_input = name.to_string(); + draft.type_input = "text".to_string(); + draft.add_from_inputs().unwrap(); + } + let names = |draft: &ColumnDraft| { + draft + .added + .iter() + .map(|column| column.name.clone()) + .collect::>() + }; + + assert_eq!( + draft.move_column(2, -1), + Some("Column `total` moved up.".to_string()) + ); + assert_eq!(names(&draft), ["number", "total", "issued_on"]); + + draft.move_column(0, 1).unwrap(); + assert_eq!(names(&draft), ["total", "number", "issued_on"]); + + // Off either end, and past the end of the list entirely. + assert_eq!(draft.move_column(0, -1), None); + assert_eq!(draft.move_column(2, 1), None); + assert_eq!(draft.move_column(9, -1), None); + assert_eq!(names(&draft), ["total", "number", "issued_on"]); + } + + /// The columns a definition row expands into are the server's answer, + /// carried by the catalog rather than written down here. + #[test] + fn a_compound_column_reports_what_it_expands_into() { + let mut draft = draft(); + draft.type_input = "accounting".to_string(); + assert!(draft.pending_is_compound()); + assert_eq!(draft.pending_compound_name(), "accounting"); + assert_eq!( + draft + .pending_generated_columns() + .iter() + .map(|generated| generated.name.as_str()) + .collect::>(), + ["name", "tax_point_date", "debit", "credit"] + ); + + draft.add_from_inputs().unwrap(); + assert_eq!(draft.generated_columns_of(0).len(), 4); + // DEBIT and CREDIT are the two kept in the declared currency. + assert_eq!( + draft + .generated_columns_of(0) + .iter() + .filter(|generated| generated.inherits_currency) + .count(), + 2 + ); + + // An ordinary column expands into nothing, and its name is its own. + draft.name_input = "number".to_string(); + draft.type_input = "text".to_string(); + assert!(!draft.pending_is_compound()); + draft.add_from_inputs().unwrap(); + assert!(draft.generated_columns_of(1).is_empty()); + } + #[test] fn indexed_columns_become_the_index_list() { let mut draft = draft(); diff --git a/web/static/app.css b/web/static/app.css index 7a777cbc..1960a7df 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -151,6 +151,22 @@ .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; } + /* A compound column's companions, in both tables: the server's columns, so + they read as a block hanging off the definition row above them. */ + .builder-table .generated-row td, .builder-table.preview .source-generated td { color: #5b6678; background: #fbfcfd; } + .builder-table .order { width: 78px; white-space: nowrap; } + .builder-table .order .hint { padding-left: 12px; } + + button.toggle.move { min-width: 26px; padding: 3px 5px; } + button.toggle.move + button.toggle.move { margin-left: 4px; } + button.toggle:disabled { color: #b3bcc9; background: #f7f8fa; cursor: default; } + + /* What a compound choice will add, shown before it is added. */ + .compound-note { margin-top: 14px; padding: 12px 14px; border: 1px solid #dfe6f3; border-radius: 8px; background: #f7f9fe; } + .compound-note p { margin: 0; font-size: 13px; color: #33415c; } + .compound-note ul { margin: 8px 0; padding-left: 18px; font-size: 13px; } + .compound-note li { padding: 1px 0; } + .compound-note .hint { font-size: 12px; } .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; } diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index c253923f..3d8b6cf0 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -86,10 +86,17 @@ {% endif %} + {# + No hx-trigger here. A text input fires `change` on blur, so mousing into + the next field would swap the whole builder out from under the click that + is still in flight: the click lands on a detached node and the field only + takes focus on a second click. Nothing rendered depends on the table name + on its own — it filters the table out of its own link-target list, and + that list is only rendered after a column-type `change`, which posts the + current name along with the rest of the form. + #} @@ -98,10 +105,17 @@

Add a column

+ {# + A compound choice is a definition row, not a column: it is named after + its own type and expands into companions the server creates. There is + nothing to name, so the field is replaced by what the choice will add. + #} + {% if !page.draft.columns.pending_is_compound() %} + {% endif %} + {% endif %}
+ + {% if page.draft.columns.pending_is_compound() %} +
+

{{ page.draft.columns.pending_compound_name() }} is a definition row, not a column of its own. Adding it gives the table:

+
    + {% for generated in page.draft.columns.pending_generated_columns() %} + {# The currency and rounding are named rather than shown: those two + inputs sit above with no `change` trigger of their own, so their + current values are only known here on the next render. #} +
  • {{ generated.name }} {{ generated.data_type }}{% if generated.inherits_currency %} — kept in the currency and rounding chosen above{% endif %}
  • + {% endfor %} + {% if page.draft.columns.pending_compound_name() == "accounting" %} +
  • account_id system column — the account each row is posted to, written as account
  • + {% endif %} +
+

These names are reserved: no column of your own may use them, and the table may hold only one {{ page.draft.columns.pending_compound_name() }}. They land where this row sits in the column list, and can be moved with it.

+
+ {% endif %}
@@ -214,34 +250,58 @@ {% if page.draft.columns.is_empty() %}

No columns yet. Describe one above and press Add column.

{% else %} +

Columns are created in this order. Use the arrows to move one.

- + - {% for column in page.draft.columns.added %} - - - + {# + Every declared column, each followed by the columns the server will + generate from it. `row.index` is what says which is which: a + generated column has none, because it is not the user's to move, + index or remove — it belongs to the definition row above it. + #} + {% for row in page.column_rows() %} + + + + + - {% endfor %}
NameTypeIndexedOptions
OrderNameTypeIndexedOptions
{{ column.name }}{{ column.data_type }}
+ {% if let Some(index) = row.index %} + + + {% else %} + + {% endif %} + {{ row.name }}{{ row.data_type }} {# A compound column expands into schema-managed companions, so there is no column of its own name to index. #} - {% if page.draft.columns.is_indexable(*loop.index0) %} + {% if row.indexable %} + {% if let Some(index) = row.index %} + {% endif %} + {% else if row.indexed %} + indexed {% else %} - + {% endif %} {% for tag in row.tags %}{{ tag }}{% endfor %} - {% if column.quantity_ledger %}quantity ledger{% endif %} - {% if !column.option_label().is_empty() %}{{ column.option_label() }}{% endif %} - + {% if let Some(index) = row.index %} + hx-vals='{"action": "remove-column", "index": "{{ index }}"}'>Remove + {% endif %}