diff --git a/web-reality-gaps.md b/web-reality-gaps.md new file mode 100644 index 00000000..5da35dab --- /dev/null +++ b/web-reality-gaps.md @@ -0,0 +1,240 @@ +# Where the web UI disagreed with the server + +Thirteen places where the Add-table builder and the append-columns panel offered, +promised or accepted something the backend does not do. All thirteen are fixed in +the `web` crate; the server is untouched. Ordered by severity. + +Severity is what the gap costs the user: + +- **Blocking** — the UI leads you to a definition the server refuses outright. +- **Wrong** — the UI states something untrue about the table you are creating. +- **Missing** — a real backend capability the UI gives you no way to reach. +- **Misleading** — accurate enough to act on, wrong enough to confuse. + +`cargo test -p web`: **158 passed**, up from 134. Clippy clean for every file +touched. + +--- + +## 1. Indexing a FKlink — blocking + +**Was:** the column panel offered `Indexing: yes/no` for a link like any other +column, and the column list gave it an index toggle. + +**Reality:** a link is a foreign key, and the server builds an index for every +one of them as it creates the table +(`server/src/table_definition/managed_table.rs:361`). Naming a link in `indexes` +is not redundant — it is refused: + +``` +Link 'billing_customer' is indexed automatically +``` + +`post_table_definition.rs:514`, and the same rule on the append path at +`add_table_columns.rs:322`. So the whole table failed to create, after the +Create button, for a checkbox the UI itself offered. + +**Now:** `ColumnDraft::is_indexable` is false for a link, the Indexing field is +replaced by *"Indexed automatically — a link is a foreign key, and the server +indexes every one of them"*, the column list reports `indexed automatically` +with no toggle, `selected_index_names` can never name a link, and a rebuilt or +tampered post is refused here with the server's own reason instead of being +forwarded. Both screens. + +## 2. Global tables and the columns that post to a profile's books — blocking + +**Was:** the shared scope offered ACCOUNTING, ACCOUNTING_TRANSFER and the +quantity-ledger switch exactly as a profile scope does. + +**Reality:** `post_table_definition.rs:280` — *"Global tables cannot use +accounting or quantity-ledger columns"*. A shared table belongs to every profile +at once, so there is no one set of books for it to post to. + +**Now:** the two types are not offered in the shared scope, the quantity-ledger +field is not rendered there, and both are refused with the reason. Switching an +existing draft to the shared scope reports it *at the switch* rather than at +save — the picker going quiet is not an explanation on its own. + +## 3. Table names could be 63 characters — blocking + +**Was:** `validate_identifier(…, "Table name")`, the plain 63-character +identifier rule. + +**Reality:** a table name is a prefix that longer identifiers are built from — +`idx___fk` has to fit in 63 too — so the server's limit is +**38** (`catalog/object_naming.rs:35`). Anything from 39 to 63 characters passed +the browser and failed at the backend. + +**Now:** `validate_table_name`, with the limit derived from the same arithmetic +and the same `common` constant the server reads, so a system column added there +moves it here too. The test asserts it lands on 38. + +## 4. Reserved table names — blocking + +**Was:** nothing stopped a table called `accounts` or `general_ledger`. + +**Reality:** every profile is given `general_ledger`, `journal_lines`, +`quantity_ledger`, `accounts` and `custom_exchange_rates` when it is created; +`post_table_definition.rs:221` refuses the names. + +**Now:** refused in the builder, naming the table and why. + +## 5. `required` was unreachable — missing + +**Was:** `proto_columns` hardcoded `required: false`. No control anywhere. + +**Reality:** `required` is a live column property. The server stores it and +enforces it on every row written +(`table_validation/runtime.rs:75`) — a row that omits a required column is +rejected. The web UI could not produce a required column at all, and could not +tell you a column was one. + +**Now:** a Required field on both column panels, carried through the form, +listed as a `required` tag, shown in the preview and sent on the request. + +## 6. The append screen could not link to a shared table — missing + +**Was:** its link-target picker read `page.tables`, which for a profile scope is +that profile's own tables. + +**Reality:** the server resolves a link against the profile's tables **or** any +global one (`post_table_definition.rs:684`). Shared tables — currencies, code +lists — were simply absent from the picker with no way to reach them. + +**Now:** link targets are their own list, built by `table_scope::linkable_tables` +(the same helper the builder uses), while the browse list stays the scope's own +tables. This is the same class of bug as the "No table chosen" one fixed earlier +in this session: one question, three answers. + +## 7. A link could point at the table being created — blocking + +**Was:** the builder filtered its own name out of the picker, and that was all. +The table-name field has no `hx-trigger` by design, so adding `link(invoice)` +and *then* naming the table `invoice` sailed through. + +**Reality:** `post_table_definition.rs:400` — *"Link 'x' cannot point at the +table being created"*. + +**Now:** a rule in `validate`, not just a filter on a list, so the order the +fields were filled in cannot get round it. + +## 8. `link(accounts)` was refused only by the server — blocking + +**Was:** the builder's loader dropped `accounts` from the picker, but nothing +validated a draft that carried the link anyway. + +**Reality:** *"The account relationship is built into ACCOUNTING and cannot be +declared as a link"* (`post_table_definition.rs:406`). + +**Now:** a rule in `validate`, shared by both screens. + +## 9. A declared column could collide with a generated one — blocking + +**Was:** duplicate names were checked against the declared columns only. + +**Reality:** the server checks against every name the table will hold, generated +ones included — *"Column 'debit' conflicts with a column generated for +ACCOUNTING"* (`models.rs:450`). Declaring `debit` beside an ACCOUNTING row was +accepted by the builder and refused by the backend. + +**Now:** `ColumnDraft::claimed_names` is the set both `add` and `validate` check, +in both directions — the declared column first or the definition row first. +Companions named after their own column (`work_phone_ext`) still never collide. + +## 10. Rows could not be identified by a generated column — missing + +**Was:** row-display candidates were the declared, non-compound columns. + +**Reality:** the server accepts any of the table's real column names, generated +ones included (`post_table_definition.rs:482`), and even maps a display column +of `accounting` onto whatever its `name` companion ended up called. A table whose +only readable column is generated — an ACCOUNTING row's `name` — had nothing to +be shown by. + +**Now:** every column the table will really hold is a candidate, under its +aliased name, and the definition row itself still is not one. + +## 11. The preview omitted `row_revision` — wrong + +**Was:** the preview claims to be "the schema as it will exist" and listed `id`, +`deleted`, … `created_at`. + +**Reality:** every managed table also carries `row_revision` +(`common/src/system_column.rs:20`). It was also missing from the names an alias +may not take, so `row_revision` could be asked for as a generated column's alias +and refused by the server. + +**Now:** in the preview and in the reserved set. + +## 12. "already exists in profile ``" — misleading + +**Was:** the duplicate-table-name error always named a profile. In the shared +scope there is no profile, so it rendered an empty pair of backticks. + +**Now:** scope-aware. The shared scope says the name has to be free in every +profile; a profile scope says the name may also be taken by a shared table, +which is what the check really covers. + +## 13. Index and options were reported twice — misleading + +**Was:** `option_label()` included `indexed`, and the column list rendered it as +a tag *beside* the Indexed column that said the same thing. + +**Now:** the list's Indexed column is the only place index state is reported; +the options column carries required, quantity ledger and currency. + +--- + +## Deliberately left alone + +- **The link's `_version` column.** Every link creates one + (`managed_table.rs:324`), and the preview does not show it. It is an internal + column — `common/src/system_column.rs` says so — never exposed by the data + API, so listing it would be noise rather than honesty. +- **`account_id` shown as well as `account`.** Both are real: one is the + physical column, one is the API name. The preview already explains the + relationship. + +--- + +## Suggested server changes + +None of these are needed for the fixes above — the web crate works around each +one — but each is a place where a client can only be correct by knowing +something the backend never tells it. + +1. **`ListColumnTypes` should say which types a shared table may not use.** + A `profile_only: bool` on `ColumnType` would let a picker be right without + naming ACCOUNTING and ACCOUNTING_TRANSFER in client code. Today + `web/src/schema/mod.rs` has to hardcode the pair (`PROFILE_ONLY_TYPES`), and a + third type with the same restriction would be silently offered. + +2. **`ListColumnTypes` should say a link is indexed automatically.** + Same shape of problem as (1): the rule is real and client-visible, but the + only way to know it is to have read `managed_table.rs`. An + `indexed_automatically: bool` would make finding #1 impossible to reintroduce + in any client. + +3. **The reserved table names should be reportable.** + `general_ledger`, `journal_lines`, `quantity_ledger`, `accounts` and + `custom_exchange_rates` are constants of three separate server modules. Every + client that wants to refuse them early has to copy the list, and will drift + when the server adds one. They belong on a capability or catalog response. + +4. **`MAX_TABLE_NAME_LENGTH` should be reportable too**, for the same reason. + The web crate now derives it from the same `common` constant the server does, + which holds only because both compile the same source file — a coincidence of + layout rather than a contract. + +5. **Projection columns are unreachable from any client.** + `post_table_definition` handles a `from(link_column.source_column)` field type + (`link_column_projection.rs:30`), but `COLUMN_TYPE_CATALOG` does not report it + and `ColumnTypeSpelling` has no variant for it — only `Bare`, `Decimal` and + `Link`. So a feature that exists in the backend cannot be offered by the web + UI or the TUI. Either add the spelling and catalog entry, or the code is dead. + +6. **The append path checks the global quantity-ledger rule only at execute + time.** `add_table_columns.rs:238` runs after the table is resolved, so a + client has to work out for itself whether the table it is appending to is + global. The web crate now infers it from the profile tree; reporting it on the + table would be sturdier. diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 5131c46e..5f9bd41c 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -13,16 +13,15 @@ use crate::{ definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest}, - schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, + schema::{ + ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name, + }, }; /// The compound type that also brings a system column with it. Which columns /// it generates is the catalog's answer; the physical `account_id` is not one /// of them, and [`TableDraft::preview_rows`] is where that is explained. -pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting"; - -/// The compound type whose connectors remain tied to their backend names. -pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer"; +pub(crate) use crate::schema::{ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE}; /// The virtual field the ACCOUNTING foreign key is written as. It is a column /// like any other once created, so it can be aliased too. @@ -143,17 +142,9 @@ impl TableDraft { self.row_display_columns.clear(); return; } - // A compound column leaves no column of its own name behind, so it can - // never identify a row; `row_display_candidates` does not offer one. - if !self.columns.is_indexable(index - 1) { - return; - } - let Some(column) = self - .columns - .added - .get(index - 1) - .map(|column| column.name.clone()) - else { + // Whatever is not on the candidate list is not the user's to choose, + // so a crafted post naming a definition row changes nothing. + let Some(column) = self.row_display_column_names().get(index - 1).cloned() else { return; }; match self @@ -168,6 +159,38 @@ impl TableDraft { } } + /// The columns a row may be identified by, in the order the table declares + /// them. + /// + /// Every column the table will really hold: the declared ones, and what the + /// definition rows among them expand into — under the names they were + /// aliased to, which is what the table will carry. A definition row itself + /// is not one, having no column of its own name. + pub(crate) fn row_display_column_names(&self) -> Vec { + let mut names = Vec::new(); + for (index, column) in self.columns.added.iter().enumerate() { + if self.columns.can_identify_row(index) { + names.push(column.name.clone()); + } + let generated = self.columns.generated_columns_of(index); + let account_reported = generated + .iter() + .any(|generated| generated.name == ACCOUNT_API_COLUMN); + names.extend( + generated + .iter() + .map(|generated| self.generated_display_name(&generated.name)), + ); + // The account link is a column of the table like the rest of + // ACCOUNTING's companions, whether or not the catalog reports it + // among them. + if column.data_type == ACCOUNTING_FIELD_TYPE && !account_reported { + names.push(self.generated_display_name(ACCOUNT_API_COLUMN)); + } + } + names + } + /// Records the tables the target profile offers as link targets. A table /// cannot link to itself, so its own name is never among them. #[cfg(test)] @@ -316,6 +339,7 @@ impl TableDraft { let mut taken: Vec = vec![ "id".to_string(), "deleted".to_string(), + "row_revision".to_string(), "created_at".to_string(), ]; taken.extend(self.columns.added.iter().map(|column| column.name.clone())); @@ -388,6 +412,15 @@ impl TableDraft { option: "default false".to_string(), source: "system".to_string(), }, + // Every managed table carries one, and the preview claims to be the + // schema as it will exist. + PreviewRow { + mark: String::new(), + column: "row_revision".to_string(), + data_type: "BIGINT".to_string(), + option: "not null, default 1".to_string(), + source: "system".to_string(), + }, ]; for (index, column) in self.columns.added.iter().enumerate() { @@ -462,14 +495,24 @@ impl TableDraft { if let Some(error) = validate_accounting_currency(self) { return Err(error); } - if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) { + if let Some(error) = validate_table_name(self.table_name.trim()) { return Err(error); } if self.table_name_conflicts() { - return Err(format!( - "A table named `{}` already exists in profile `{}`.", - self.table_name, profile_name - )); + // A shared table lands in every profile at once, so its name has to + // be free in all of them — and naming a profile in that message + // would name the wrong thing, there being none. + return Err(if self.global { + format!( + "A table named `{}` already exists. A shared table's name has to be free in every profile.", + self.table_name + ) + } else { + format!( + "A table named `{}` already exists in profile `{profile_name}`, or is shared by every profile.", + self.table_name + ) + }); } if self.columns.is_empty() { return Err("Add at least one column before saving.".to_string()); @@ -530,6 +573,7 @@ mod tests { data_type: data_type.to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: if matches!(data_type, "money" | "accounting") { "EUR".to_string() @@ -648,6 +692,7 @@ mod tests { data_type: "text".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -685,7 +730,15 @@ mod tests { let mut draft = draft_with_column("total", "int"); draft.existing_profile_tables = vec!["invoice".to_string()]; - assert!(draft.validate().is_err()); + let error = draft.validate().unwrap_err(); + assert!(error.contains("profile `billing`"), "{error}"); + + // A shared table has no profile to name, and its name has to be free + // everywhere rather than in one place. + draft.global = true; + let error = draft.validate().unwrap_err(); + assert!(!error.contains("profile ``"), "{error}"); + assert!(error.contains("every profile"), "{error}"); } #[test] @@ -721,6 +774,7 @@ mod tests { data_type: "date".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -735,24 +789,49 @@ mod tests { assert!(draft.row_display_columns.is_empty()); } - /// A compound column expands into schema-managed companions, so there is - /// no column of that name for a row to be identified by — the builder does - /// not offer it, and a crafted post cannot choose it either. + /// A definition row expands into schema-managed companions, so there is no + /// column of *its* name for a row to be identified by — but the columns it + /// expands into are columns like any other, and the server takes them here. + /// Offering only the declared ones is what left a table whose only readable + /// column was generated with nothing to be shown by. #[test] - fn a_compound_column_never_identifies_a_row() { + fn a_row_is_identified_by_the_columns_the_table_really_holds() { 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, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); - draft.toggle_row_display_candidate(2); + // The declared column, then what the definition row generates — and + // never the definition row itself. + assert_eq!( + draft.row_display_column_names(), + ["number", "name", "tax_point_date", "debit", "credit", "account"] + ); + assert!(!draft.row_display_column_names().contains(&"accounting".to_string())); - assert!(draft.row_display_columns.is_empty()); + // `name` is the second candidate, and choosing it names the generated + // column rather than the row it came from. + draft.toggle_row_display_candidate(2); + assert_eq!(draft.row_display_columns, vec!["name"]); + + // Under the name it was aliased to, which is what the table will carry. + draft.toggle_row_display_candidate(2); + draft.generated_aliases = vec![GeneratedAlias { + source: "name".to_string(), + alias: "popis".to_string(), + }]; + draft.toggle_row_display_candidate(2); + assert_eq!(draft.row_display_columns, vec!["popis"]); + + // Past the end of the candidate list nothing is chosen. + draft.toggle_row_display_candidate(99); + assert_eq!(draft.row_display_columns, vec!["popis"]); } #[test] @@ -775,7 +854,10 @@ mod tests { .map(|row| row.column.as_str()) .collect::>(); - assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]); + assert_eq!( + columns, + vec!["id", "deleted", "row_revision", "number", "created_at"] + ); // No display column chosen, so `id` identifies the row. assert_eq!(rows[0].mark, "[x]"); } @@ -791,6 +873,7 @@ mod tests { data_type: "date".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -821,6 +904,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Rounded, currency: "CZK".to_string(), }); @@ -836,6 +920,7 @@ mod tests { vec![ "id", "deleted", + "row_revision", "number", "accounting", "name", @@ -871,4 +956,98 @@ mod tests { assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]); } + + /// The request the builder sends is one the server will take: a link + /// carries no index of the request's own — the server makes that one — and + /// `required` travels with the column it belongs to. + #[test] + fn the_request_names_no_link_among_its_indexes_and_carries_required() { + let mut draft = draft_with_column("number", "text"); + draft.columns.added[0].required = true; + draft.columns.added.push(ColumnDefinition { + name: "billing_customer".to_string(), + data_type: "link(customer)".to_string(), + // As a tampered post would have it. + indexed: true, + quantity_ledger: false, + required: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + + // Refused rather than quietly repaired, because the server refuses it. + let error = draft.clone().into_request().unwrap_err(); + assert!(error.contains("indexed automatically"), "{error}"); + + draft.columns.added[1].indexed = false; + draft.columns.toggle_indexed(0); + let request = draft.into_request().unwrap(); + + assert_eq!(request.indexes, vec!["number"]); + assert!(request.columns[0].required); + assert!(!request.columns[1].required); + } + + /// A table name has less room than a column name, and the names every + /// profile's own tables carry are not free. + #[test] + fn a_table_name_is_held_to_the_rules_a_table_name_has() { + let mut draft = draft_with_column("total", "int"); + + draft.table_name = "t".repeat(39); + let error = draft.validate().unwrap_err(); + assert!(error.contains("38 characters"), "{error}"); + + draft.table_name = "accounts".to_string(); + assert!(draft.validate().is_err()); + draft.table_name = "general_ledger".to_string(); + assert!(draft.validate().is_err()); + + draft.table_name = "invoice".to_string(); + assert!(draft.validate().is_ok()); + } + + /// A shared table has no books of its own, so the two definition rows that + /// post to a profile's books are refused on one. + #[test] + fn a_shared_table_refuses_the_columns_that_belong_to_one_profile() { + let mut draft = draft_with_column("accounting", "accounting"); + draft.global = true; + draft.columns.global = true; + + assert!(draft.validate().is_err()); + + draft.columns.added.clear(); + draft.columns.added.push(ColumnDefinition { + name: "quantity".to_string(), + data_type: "int".to_string(), + indexed: false, + quantity_ledger: true, + required: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + assert!(draft.validate().is_err()); + } + + /// The preview is the schema as it will exist, so it carries every system + /// column the server puts on a managed table — including the one an alias + /// may therefore not take. + #[test] + fn the_preview_and_the_aliases_know_every_system_column() { + let draft = draft_with_column("number", "text"); + assert!( + draft + .preview_rows() + .iter() + .any(|row| row.column == "row_revision" && row.source == "system") + ); + + let mut draft = draft_with_column("accounting", "accounting"); + draft.generated_aliases = vec![GeneratedAlias { + source: "name".to_string(), + alias: "row_revision".to_string(), + }]; + assert!(draft.validate_generated_aliases().is_err()); + } } diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index a564534e..a8b25889 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -54,6 +54,12 @@ pub(crate) async fn load_page( .column_types, ); + // The panel holds a column to the rules of the table it is being described + // for, so it is told what that table is on every render rather than left to + // read a copy that a `refresh` could have moved on from. + draft.columns.global = draft.global; + draft.columns.table_name = draft.table_name.trim().to_ascii_lowercase(); + let tree = definitions .get_profile_tree( authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, @@ -88,7 +94,7 @@ pub(crate) async fn load_page( .into_iter() // The profile's ledger accounts are not a link target of their own: an // ACCOUNTING column is how a row is posted to one. - .filter(|table| table.name != "accounts") + .filter(|table| table.name != crate::schema::ACCOUNTS_TABLE) .map(|table| RelationTableOption { name: table.name, global: table.global, diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 59a583c5..0b17a9b7 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -176,6 +176,16 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { }, "toggle-index" => page.draft.columns.toggle_indexed(index), "toggle-display" => page.draft.toggle_row_display_candidate(index), + // Picking a scope or a type only changes which fields apply, so there + // is nothing to do — except when the change has just made a column + // already in the list impossible. Switching to the shared scope is the + // one that does: the picker stops offering the columns that post to a + // profile's books, and one added before the switch is still there. + "refresh" => { + if let Err(message) = page.draft.columns.validate() { + page.error = Some(message); + } + } _ => {} } } @@ -197,3 +207,57 @@ fn load_error_response(error: LoadError) -> Response { .into_response(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{ColumnDefinition, MoneyMode}; + + fn page_with_accounting() -> AddTablePageState { + let mut draft = TableDraft::new(); + draft.columns.catalog = crate::schema::tests::catalog(); + draft.profile_name = "billing".to_string(); + draft.table_name = "invoice".to_string(); + draft.columns.added.push(ColumnDefinition { + name: "accounting".to_string(), + data_type: "accounting".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: MoneyMode::Exact, + currency: "EUR".to_string(), + }); + AddTablePageState { + nav: crate::ui::Nav::default(), + profiles: vec!["billing".to_string()], + draft, + status: None, + error: None, + } + } + + fn refresh() -> BuilderForm { + BuilderForm { + action: "refresh".to_string(), + ..BuilderForm::default() + } + } + + /// Switching an ACCOUNTING draft to the shared scope says so at once. Left + /// to the save, the column stays in a list whose picker no longer offers it + /// and the reason only arrives after the Create button. + #[test] + fn switching_to_the_shared_scope_reports_a_column_that_cannot_come_along() { + let mut page = page_with_accounting(); + apply_action(&mut page, &refresh()); + assert!(page.error.is_none(), "nothing is wrong yet"); + + // What the loader does with the posted scope before the action runs. + page.draft.global = true; + page.draft.columns.global = true; + apply_action(&mut page, &refresh()); + + let error = page.error.expect("the switch is refused out loud"); + assert!(error.contains("shared table"), "{error}"); + } +} diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 219854ab..3f658def 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -61,6 +61,8 @@ pub(crate) struct BuilderForm { #[serde(default)] pub column_quantity_ledger_input: String, #[serde(default)] + pub column_required_input: String, + #[serde(default)] pub column_rounding_input: String, #[serde(default)] pub column_currency_input: String, @@ -75,6 +77,8 @@ pub(crate) struct BuilderForm { #[serde(default)] pub column_quantity_ledger: Vec, #[serde(default)] + pub column_required: Vec, + #[serde(default)] pub column_rounding: Vec, #[serde(default)] pub column_currencies: Vec, @@ -113,6 +117,7 @@ impl BuilderForm { decimal_scale_input: self.decimal_scale_input.clone(), indexing_input: self.column_indexing_input.clone(), quantity_ledger_input: self.column_quantity_ledger_input.clone(), + required_input: self.column_required_input.clone(), rounding_input: self.column_rounding_input.clone(), currency_input: self.column_currency_input.clone(), added: columns_from_rows( @@ -120,6 +125,7 @@ impl BuilderForm { &self.column_types, &self.column_indexed, &self.column_quantity_ledger, + &self.column_required, &self.column_rounding, &self.column_currencies, ), @@ -129,6 +135,12 @@ impl BuilderForm { // The table is being created here, so the creation-only types are // on the table. creating_table: true, + // What the table will be, so the panel can hold a column to the + // rules of the table it is being described for: a shared table + // takes no column that posts to one profile's books, and no link + // may point at the table being created. + global: self.global, + table_name: self.table_name.trim().to_ascii_lowercase(), }; // Drop display columns whose column is gone, so a stale post cannot @@ -206,12 +218,17 @@ impl AddTablePageState { let mut rows = Vec::new(); for (index, column) in columns.added.iter().enumerate() { + // Whether it is indexed has a column of its own, so it is not + // repeated here. let mut tags = Vec::new(); + if column.required { + tags.push("required".to_string()); + } if column.quantity_ledger { tags.push("quantity ledger".to_string()); } - if !column.option_label().is_empty() { - tags.push(column.option_label()); + if !column.currency.is_empty() { + tags.push(format!("{}, {}", column.currency, column.money_mode.label())); } rows.push(ColumnRow { index: Some(index), @@ -220,7 +237,9 @@ impl AddTablePageState { name: column.name.clone(), data_type: column.data_type.clone(), indexable: columns.is_indexable(index), - indexed: column.indexed, + // A link is indexed whether or not anyone asked: the server + // builds an index for every foreign key. + indexed: column.is_indexed(), tags, alias_source: None, alias: String::new(), @@ -313,8 +332,17 @@ impl AddTablePageState { rows } - /// Row-display candidates: `id` first, then every column, matching the - /// client's candidate list. + /// Row-display candidates: `id` first, then every column the table will + /// really hold. + /// + /// That includes the columns a definition row generates — they are columns + /// like any other once the table exists, and the server accepts them here. + /// The definition row itself is not among them: it leaves no column of its + /// own name behind for a row to be shown by. + /// + /// The index is the candidate's place in this list, so the button that + /// toggles one names the same column the label does however the list is + /// filtered. pub(crate) fn row_display_candidates(&self) -> Vec { let mut candidates = vec![RowDisplayCandidate { index: 0, @@ -325,21 +353,13 @@ impl AddTablePageState { None }, }]; - candidates.extend( - self.draft - .columns - .added - .iter() - .enumerate() - // A compound column expands into schema-managed companions, so - // there is no column of that name for a row to be shown by. - .filter(|(index, _)| self.draft.columns.is_indexable(*index)) - .map(|(index, column)| RowDisplayCandidate { - index: index + 1, - name: column.name.clone(), - position: self.draft.row_display_position(&column.name), - }), - ); + for name in self.draft.row_display_column_names() { + candidates.push(RowDisplayCandidate { + index: candidates.len(), + position: self.draft.row_display_position(&name), + name, + }); + } candidates } } @@ -409,6 +429,7 @@ mod tests { column_types: vec!["text".into(), "money".into()], column_indexed: vec!["yes".into(), "no".into()], column_quantity_ledger: vec!["no".into(), "no".into()], + column_required: vec!["yes".into(), "no".into()], column_rounding: vec!["exact".into(), "half-up".into()], column_currencies: vec![String::new(), "EUR".into()], relation_tables: vec!["customer".into(), "project".into()], @@ -480,6 +501,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -523,6 +545,7 @@ mod tests { form.column_types.push("accounting".into()); form.column_indexed.push("no".into()); form.column_quantity_ledger.push("no".into()); + form.column_required.push("no".into()); form.column_rounding.push("exact".into()); form.column_currencies.push("EUR".into()); form.generated_alias_sources = vec!["debit".into(), "account".into()]; diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 5c02b69c..f9ac1846 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -77,6 +77,7 @@ mod tests { data_type: "text".to_string(), indexed: true, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -136,6 +137,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -172,6 +174,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -199,6 +202,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -314,6 +318,7 @@ mod tests { data_type: "money".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -337,6 +342,7 @@ mod tests { data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); @@ -387,6 +393,115 @@ mod tests { assert!(html.contains("htmx:beforeSwap")); } + /// Choosing FKlink takes the Indexing choice away and says who makes the + /// index instead. Offering the choice is what sent a definition the server + /// refuses outright — a link is indexed automatically. + #[test] + fn choosing_a_link_replaces_the_index_choice_with_who_makes_it() { + let mut state = page(); + assert!(render_builder(&state).contains(r#"name="column_indexing_input""#)); + + state.draft.columns.type_input = "link".to_string(); + let html = render_builder(&state); + + assert!(!html.contains(r#"name="column_indexing_input""#), "{html}"); + assert!(html.contains("Indexed automatically")); + // The rest of the link's own fields are still there. + assert!(html.contains(r#"name="link_table_input""#)); + assert!(html.contains("Link alias")); + } + + /// And a link already in the list is reported as indexed, without a toggle + /// that would do nothing. + #[test] + fn a_link_in_the_column_list_is_shown_as_indexed_by_the_server() { + let mut state = page(); + state.draft.columns.added.push(ColumnDefinition { + name: "billing_customer".to_string(), + data_type: "link(customer)".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + + let html = render_builder(&state); + + assert!(html.contains("indexed automatically"), "{html}"); + // `number` keeps its toggle; the link is offered none. + assert!(html.contains(r#""action": "toggle-index", "index": "0""#)); + assert!(!html.contains(r#""action": "toggle-index", "index": "1""#)); + } + + /// A column can be made required, and the answer travels with the draft. + #[test] + fn a_column_can_be_required_and_says_so_in_the_list() { + let mut state = page(); + assert!(render_builder(&state).contains(r#"name="column_required_input""#)); + + state.draft.columns.added[0].required = true; + let html = render_builder(&state); + + assert!(html.contains(r#"name="column_required" value="yes""#)); + assert!(html.contains(r#"required"#)); + } + + /// A shared table keeps no books, so the columns that post to a profile's + /// books are not on offer and the page says why. + #[test] + fn the_shared_scope_offers_no_column_that_belongs_to_one_profile() { + let mut state = page(); + state.draft.global = true; + state.draft.columns.global = true; + + let html = render_builder(&state); + + assert!(!html.contains(r#"
__fk` has to fit in 63 as well. What is subtracted is +/// that wrapping plus the longest physical column name there can be. +/// +/// This is the server's own arithmetic — `catalog::object_naming` — down to the +/// term it reads out of `common`, so adding a system column there moves the +/// limit here too instead of leaving this behind at a number that was once +/// right. +pub(crate) const MAX_TABLE_NAME_LENGTH: usize = MAX_IDENTIFIER_LENGTH + - "idx_".len() + - "_".len() + - "_fk".len() + - crate::system_column::LONGEST_SYSTEM_COLUMN_NAME; + +/// The tables the server provisions for a profile itself. A profile gets these +/// when it is created, so a new table may not claim one of their names. +/// +/// Written down here because the backend does not report them: they are +/// constants of three server modules rather than anything the catalog carries. +/// A name the server adds to that set is a name this list will not know about +/// until it is added here as well. +pub(crate) const RESERVED_TABLE_NAMES: [&str; 5] = [ + "general_ledger", + "journal_lines", + "quantity_ledger", + ACCOUNTS_TABLE, + "custom_exchange_rates", +]; + +/// The identifier rules, plus the two a table name alone is held to. +pub(crate) fn validate_table_name(value: &str) -> Option { + if let Some(error) = validate_identifier(value, "Table name", true) { + return Some(error); + } + if value.len() > MAX_TABLE_NAME_LENGTH { + return Some(format!( + "Table name cannot be longer than {MAX_TABLE_NAME_LENGTH} characters, because the \ + indexes on its columns are named after it." + )); + } + if RESERVED_TABLE_NAMES.contains(&value) { + return Some(format!( + "`{value}` is the name of a table every profile is given, so it cannot be reused." + )); + } + None +} + /// The precision and scale rules the server applies to `decimal(p,s)`: /// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`. fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> { @@ -852,7 +1150,7 @@ pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec, #[serde(default)] + pub column_required: Vec, + #[serde(default)] pub column_rounding: Vec, #[serde(default)] pub column_currencies: Vec, @@ -973,6 +1275,7 @@ impl ColumnForm { decimal_scale_input: self.decimal_scale_input.clone(), indexing_input: self.column_indexing_input.clone(), quantity_ledger_input: self.column_quantity_ledger_input.clone(), + required_input: self.column_required_input.clone(), rounding_input: self.column_rounding_input.clone(), currency_input: self.column_currency_input.clone(), added: columns_from_rows( @@ -980,11 +1283,16 @@ impl ColumnForm { &self.column_types, &self.column_indexed, &self.column_quantity_ledger, + &self.column_required, &self.column_rounding, &self.column_currencies, ), catalog, creating_table, + // Filled in by the page, which is what knows the table these + // columns are for: the panel's own form carries neither. + global: false, + table_name: String::new(), } } } @@ -1003,6 +1311,7 @@ pub(crate) fn columns_from_rows( types: &[String], indexed: &[String], quantity_ledger: &[String], + required: &[String], rounding: &[String], currencies: &[String], ) -> Vec { @@ -1011,6 +1320,7 @@ pub(crate) fn columns_from_rows( types.len(), indexed.len(), quantity_ledger.len(), + required.len(), rounding.len(), currencies.len(), ] @@ -1024,6 +1334,7 @@ pub(crate) fn columns_from_rows( data_type: types[index].clone(), indexed: is_yes(&indexed[index]), quantity_ledger: is_yes(&quantity_ledger[index]), + required: is_yes(&required[index]), money_mode: MoneyMode::from_input(&rounding[index]), currency: currencies[index].clone(), }) @@ -1332,6 +1643,7 @@ pub(crate) mod tests { data_type: field_type.to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -1464,6 +1776,7 @@ pub(crate) mod tests { data_type: "money".to_string(), indexed: false, quantity_ledger: false, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -1489,6 +1802,7 @@ pub(crate) mod tests { data_type: "text".to_string(), indexed: false, quantity_ledger: true, + required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); @@ -1530,6 +1844,7 @@ pub(crate) mod tests { &["text".to_string()], &["yes".to_string(), "no".to_string()], &["no".to_string(), "no".to_string()], + &["no".to_string(), "yes".to_string()], &["exact".to_string(), "half-up".to_string()], &[String::new(), "EUR".to_string()], ); @@ -1622,6 +1937,259 @@ pub(crate) mod tests { assert_eq!(draft.selected_index_names(), vec!["number"]); } + + /// A link is a foreign key, and the server indexes every one of them as it + /// creates the table. Asking for one on top is not redundant — the server + /// refuses the whole definition, saying the link is indexed automatically — + /// so there is no choice to offer and no way to end up having made one. + #[test] + fn a_link_is_indexed_by_the_server_and_never_by_the_user() { + let mut draft = draft(); + draft.name_input = "billing_customer".to_string(); + draft.type_input = "link".to_string(); + draft.link_table_input = "customer".to_string(); + draft.indexing_input = "yes".to_string(); + + // The panel does not offer the choice, and says why instead. + assert!(!draft.show_indexing()); + assert!(draft.pending_is_auto_indexed()); + + draft.add_from_inputs().unwrap(); + + // Asking anyway leaves the column unindexed, so nothing names it. + assert!(!draft.added[0].indexed); + assert!(!draft.is_indexable(0)); + draft.toggle_indexed(0); + assert!(!draft.added[0].indexed); + assert!(draft.selected_index_names().is_empty()); + + // And it is still reported as indexed, because it is. + assert!(draft.added[0].is_indexed()); + assert!(draft.added[0].option_label().contains("indexed")); + + // A draft rebuilt from a post that says otherwise is refused rather + // than sent on to be refused by the server. + draft.added[0].indexed = true; + let error = draft.validate().unwrap_err(); + assert!(error.contains("indexed automatically"), "{error}"); + assert!(draft.selected_index_names().is_empty()); + } + + /// A definition row is never indexed: it leaves no column of its own name + /// behind. A crafted post that marks one is refused rather than silently + /// dropped from the request, matching how a crafted link is refused. + #[test] + fn a_compound_column_marked_indexed_is_refused_not_dropped() { + let mut draft = draft(); + draft.type_input = "accounting".to_string(); + draft.currency_input = "EUR".to_string(); + draft.add_from_inputs().unwrap(); + assert!(!draft.added[0].indexed); + + draft.added[0].indexed = true; + let error = draft.validate().unwrap_err(); + assert!(error.contains("cannot be indexed"), "{error}"); + assert!(draft.selected_index_names().is_empty()); + } + + /// An ordinary column is still the user's to index. + #[test] + fn every_other_column_still_chooses_its_own_index() { + let mut draft = draft(); + draft.name_input = "number".to_string(); + draft.type_input = "text".to_string(); + assert!(draft.show_indexing()); + assert!(!draft.pending_is_auto_indexed()); + draft.indexing_input = "yes".to_string(); + draft.add_from_inputs().unwrap(); + + assert!(draft.is_indexable(0)); + assert_eq!(draft.selected_index_names(), vec!["number"]); + assert!(draft.validate().is_ok()); + } + + /// Both types post to one profile's books, and a shared table belongs to + /// every profile at once. The server refuses the pair outright, so they are + /// not offered on a shared table. + #[test] + fn a_shared_table_is_offered_no_column_that_posts_to_a_profiles_books() { + let mut draft = ColumnDraft::new(catalog()); + draft.global = true; + + let offered = draft.offered_types(); + assert!(!offered.contains(&"accounting".to_string())); + assert!(!offered.contains(&"accounting_transfer".to_string())); + assert!(offered.contains(&"money".to_string())); + + for field_type in ["accounting", "accounting_transfer"] { + let mut draft = draft.clone(); + draft.type_input = field_type.to_string(); + let error = draft.add_from_inputs().unwrap_err(); + assert!(error.contains("shared table"), "{error}"); + } + + // And a draft rebuilt from a post that carries one anyway. + draft.added.push(ColumnDefinition { + name: "accounting".to_string(), + data_type: "accounting".to_string(), + indexed: false, + quantity_ledger: false, + required: false, + money_mode: MoneyMode::Exact, + currency: "EUR".to_string(), + }); + assert!(draft.validate().is_err()); + } + + /// A quantity ledger is one profile's, for the same reason. + #[test] + fn a_shared_table_keeps_no_quantity_ledger() { + let mut draft = ColumnDraft::new(catalog()); + draft.global = true; + assert!(!draft.show_quantity_ledger()); + + draft.name_input = "quantity".to_string(); + draft.type_input = "int".to_string(); + draft.quantity_ledger_input = "yes".to_string(); + let error = draft.add_from_inputs().unwrap_err(); + assert!(error.contains("quantity ledger"), "{error}"); + + draft.added.push(ColumnDefinition { + name: "quantity".to_string(), + data_type: "int".to_string(), + indexed: false, + quantity_ledger: true, + required: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + assert!(draft.validate().is_err()); + + // The same column on a profile's own table is fine. + draft.global = false; + assert!(draft.show_quantity_ledger()); + assert!(draft.validate().is_ok()); + } + + /// The table does not exist yet, so it cannot be pointed at — the picker + /// leaves it out, and the rule holds even when the name is typed after the + /// link was added. + #[test] + fn a_link_cannot_point_at_the_table_being_created() { + let mut draft = draft(); + draft.table_name = "invoice".to_string(); + draft.name_input = "parent".to_string(); + draft.type_input = "link".to_string(); + draft.link_table_input = "invoice".to_string(); + + let error = draft.add_from_inputs().unwrap_err(); + assert!(error.contains("cannot point at the table"), "{error}"); + + // Added while the table had another name, then renamed to the target. + draft.table_name = "order".to_string(); + draft.add_from_inputs().unwrap(); + assert!(draft.validate().is_ok()); + draft.table_name = "invoice".to_string(); + assert!(draft.validate().is_err()); + } + + /// A row reaches the chart of accounts through an ACCOUNTING definition + /// row. Declaring the link by hand is refused by the server, so it is + /// refused here. + #[test] + fn the_chart_of_accounts_is_not_a_link_target() { + let mut draft = draft(); + draft.name_input = "posted_to".to_string(); + draft.type_input = "link".to_string(); + draft.link_table_input = ACCOUNTS_TABLE.to_string(); + + let error = draft.add_from_inputs().unwrap_err(); + assert!(error.contains("built into ACCOUNTING"), "{error}"); + } + + /// The names a definition row generates are the table's columns too, so a + /// declared column may not take one — in either order. + #[test] + fn a_declared_column_cannot_take_a_generated_columns_name() { + let mut draft = draft(); + draft.type_input = "accounting".to_string(); + draft.currency_input = "EUR".to_string(); + draft.add_from_inputs().unwrap(); + assert!(draft.claimed_names().contains(&"debit".to_string())); + + draft.name_input = "debit".to_string(); + draft.type_input = "text".to_string(); + assert!(draft.add_from_inputs().is_err(), "ACCOUNTING generates it"); + + // And the other way round: the declared column first, then the row + // whose expansion would collide with it. + let mut reversed = ColumnDraft::new(catalog()); + reversed.name_input = "credit".to_string(); + reversed.type_input = "text".to_string(); + reversed.add_from_inputs().unwrap(); + reversed.type_input = "accounting".to_string(); + reversed.currency_input = "EUR".to_string(); + let error = reversed.add_from_inputs().unwrap_err(); + assert!(error.contains("credit"), "{error}"); + + // A companion named after its own column follows that column's name, + // so two PHONE columns never collide. + let mut phones = ColumnDraft::new(catalog()); + for name in ["home_phone", "work_phone"] { + phones.name_input = name.to_string(); + phones.type_input = "phone".to_string(); + phones.add_from_inputs().unwrap(); + } + assert!(phones.claimed_names().contains(&"work_phone_ext".to_string())); + assert!(phones.validate().is_ok()); + } + + /// `required` is a column property the server records and enforces on every + /// row written, so it travels with the column like any other. + #[test] + fn a_column_can_be_required() { + let mut draft = draft(); + draft.name_input = "number".to_string(); + draft.type_input = "text".to_string(); + draft.required_input = "yes".to_string(); + draft.add_from_inputs().unwrap(); + + assert!(draft.added[0].required); + assert!(draft.added[0].option_label().contains("required")); + assert!(proto_columns(&draft.added)[0].required); + + // And the panel is cleared for the next column, which is not required + // just because the last one was. + assert_eq!(draft.required_input, "no"); + } + + /// A table name is not simply an identifier: the server names every index + /// on the table after it, so it has less room than a column does. + #[test] + fn a_table_name_is_shorter_than_a_column_name() { + assert_eq!(MAX_TABLE_NAME_LENGTH, 38); + + let longest = "t".repeat(MAX_TABLE_NAME_LENGTH); + assert_eq!(validate_table_name(&longest), None); + assert_eq!(validate_identifier(&longest, "Column name", true), None); + + let too_long = "t".repeat(MAX_TABLE_NAME_LENGTH + 1); + assert!(validate_table_name(&too_long).is_some()); + // Still a perfectly good column name, which is why the two differ. + assert_eq!(validate_identifier(&too_long, "Column name", true), None); + } + + /// Every profile is given these tables when it is created, so a new table + /// cannot be named after one of them. + #[test] + fn the_tables_every_profile_is_given_keep_their_names() { + for name in RESERVED_TABLE_NAMES { + let error = validate_table_name(name) + .unwrap_or_else(|| panic!("`{name}` should be reserved")); + assert!(error.contains(name), "{error}"); + } + assert_eq!(validate_table_name("invoice"), None); + } } #[cfg(test)] diff --git a/web/static/app.css b/web/static/app.css index 4526e390..4018dc9a 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -83,6 +83,11 @@ .form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; } .form-grid label.wide { grid-column: 1 / -1; } .form-grid small { color: #7c8796; } + /* Where a field would be, when the answer is the server's rather than the + user's — a link's index, which the server always makes. It sits in the + grid like the label it replaces. */ + .form-grid .field-note { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; } + .form-grid .field-note p { margin: 0; } .form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; } .form-actions a { color: #59677a; } .form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; } diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 49d0142a..4f1487d3 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -205,15 +205,35 @@ {% endif %} - {# Neither applies to a definition row: it leaves no column to index, and - nothing to keep a quantity ledger on. #} + {# None of the three applies to a definition row: it leaves no column to + index, nothing to keep a quantity ledger on, and no value to require. #} {% if !page.draft.columns.pending_is_compound() %} + {# + A link is left out rather than defaulted to "no": the server builds an + index for every foreign key as it creates the table, and refuses a + definition that asks for one on top — so there is no choice to offer. + #} + {% if page.draft.columns.show_indexing() %} + {% else if page.draft.columns.pending_is_auto_indexed() %} +
+ Indexing +

Indexed automatically — a link is a foreign key, and the server indexes every one of them.

+
+ {% endif %} + + {% if page.draft.columns.show_quantity_ledger() %} {% endif %} + {# + A link is left out rather than defaulted to "no": the server builds an + index for every foreign key as it adds the column, and refuses a request + that asks for one on top. + #} + {% if page.columns.show_indexing() %} + {% else if page.columns.pending_is_auto_indexed() %} +
+ Indexing +

Indexed automatically — a link is a foreign key, and the server indexes every one of them.

+
+ {% endif %} + + {% if page.columns.show_quantity_ledger() %} + {% else %} +

A shared table keeps no books of its own, so it cannot hold a quantity-ledger column.

+ {% endif %} + {% else if column.is_indexed() %} + indexed automatically {% else %} {% endif %}
+ {% if column.required %}required{% endif %} {% if column.quantity_ledger %}quantity ledger{% endif %} - {% if !column.option_label().is_empty() %}{{ column.option_label() }}{% endif %} + {% if !column.currency.is_empty() %}{{ column.currency }}, {{ column.money_mode.label() }}{% endif %}