From 8662b00ccf6844d891a82108933996bef94930aa Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 13 Aug 2026 19:05:41 +0200 Subject: [PATCH] web aliasing of accounting and others --- server | 2 +- web/src/pages/add_table/draft.rs | 30 +++++++---- web/src/pages/add_table/state.rs | 63 +++++++++++++++++++--- web/src/pages/add_table/ui.rs | 35 +++++++++++- web/src/schema/mod.rs | 7 ++- web/static/app.css | 11 ++++ web/templates/pages/add_table/builder.html | 46 +++++++++++----- 7 files changed, 161 insertions(+), 33 deletions(-) diff --git a/server b/server index 07348044..3b7914b5 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 0734804445ab1700869fe5748544987e885495f7 +Subproject commit 3b7914b5e69dd56b9246ba8b1d390fd1668dc0da diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index ccf5ec76..cb69f3cb 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -17,8 +17,8 @@ use crate::{ }; /// 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. +/// 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. @@ -237,13 +237,19 @@ impl TableDraft { if column.data_type == ACCOUNTING_TRANSFER_FIELD_TYPE { continue; } - names.extend( - self.columns - .generated_columns_of(index) - .iter() - .map(|generated| generated.name.clone()), - ); - if column.data_type == ACCOUNTING_FIELD_TYPE { + let generated: Vec = self + .columns + .generated_columns_of(index) + .iter() + .map(|generated| generated.name.clone()) + .collect(); + // A backend that reports `account` with the rest of ACCOUNTING's + // companions has already named it here; offering it a second time + // for the foreign key would put two fields on one column, and only + // the first of the two would be read. + let account_reported = generated.iter().any(|name| name == ACCOUNT_API_COLUMN); + names.extend(generated); + if column.data_type == ACCOUNTING_FIELD_TYPE && !account_reported { names.push(ACCOUNT_API_COLUMN.to_string()); } } @@ -831,8 +837,10 @@ mod tests { "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. + // The foreign key to the profile's accounts, once as the + // column the backend reports and once as the physical column + // it is stored in. + "account", "account_id", "created_at", ] diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 9f7783b7..219854ab 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -231,7 +231,15 @@ impl AddTablePageState { // backend names and remain the exception. let aliasable = column.data_type != ACCOUNTING_TRANSFER_FIELD_TYPE; - for generated in columns.generated_columns_of(index) { + let generated_columns = columns.generated_columns_of(index); + // Whether the backend already reports `account` as one of them, in + // which case the foreign-key row below is a second view of a column + // that is named once, not a column of its own to name again. + let account_reported = generated_columns + .iter() + .any(|generated| generated.name == ACCOUNT_API_COLUMN); + + for generated in generated_columns { let mut tags = vec![format!("generated by {}", column.data_type)]; if generated.inherits_currency { tags.push(format!("{}, {}", column.currency, column.money_mode.label())); @@ -250,9 +258,10 @@ impl AddTablePageState { }); } - // 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`. + // The physical column the account foreign key is stored in. It is + // a system column, so it is listed whether or not the catalog + // reports `account` among the generated ones — but it is named + // there when it does, never twice. if column.data_type == ACCOUNTING_FIELD_TYPE { rows.push(ColumnRow { index: None, @@ -269,7 +278,7 @@ impl AddTablePageState { self.draft.generated_display_name(ACCOUNT_API_COLUMN) ), ], - alias_source: Some(ACCOUNT_API_COLUMN.to_string()), + alias_source: (!account_reported).then(|| ACCOUNT_API_COLUMN.to_string()), alias: self.draft.alias_for(ACCOUNT_API_COLUMN).to_string(), }); } @@ -277,6 +286,33 @@ impl AddTablePageState { rows } + /// The generated columns that may be renamed, in the order they are + /// listed. + /// + /// Renaming lives in a section of its own rather than in the column list: + /// the list is where the table is read, and a text box in every other row + /// of it turns reading into scanning past inputs. + /// One field per generated column and no more: a source named twice would + /// be two fields over one column, and the draft reads the first of them, + /// so whatever was typed in the second would be dropped without a word. + pub(crate) fn alias_rows(&self) -> Vec { + let mut rows: Vec = Vec::new(); + for row in self.column_rows() { + let Some(source) = row.alias_source else { + continue; + }; + if rows.iter().any(|existing| existing.source == source) { + continue; + } + rows.push(AliasRow { + source, + alias: row.alias, + data_type: row.data_type, + }); + } + rows + } + /// Row-display candidates: `id` first, then every column, matching the /// client's candidate list. pub(crate) fn row_display_candidates(&self) -> Vec { @@ -331,6 +367,16 @@ pub(crate) struct ColumnRow { pub alias: String, } +/// One line of the "Rename generated columns" section: a generated column the +/// request may name, and the name asked for it so far. +pub(crate) struct AliasRow { + /// The generated column's own name, which is what the rename asks for. + pub source: String, + /// The alias typed for it, empty when the backend's name is being kept. + pub alias: String, + pub data_type: String, +} + pub(crate) struct RowDisplayCandidate { pub index: usize, pub name: String, @@ -449,6 +495,7 @@ mod tests { "tax_point_date", "debit", "credit", + "account", "account_id", ] ); @@ -504,7 +551,11 @@ mod tests { let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap(); assert_eq!(row("debit").alias_source.as_deref(), Some("debit")); assert_eq!(row("debit").alias, "md"); - assert_eq!(row("account_id").alias_source.as_deref(), Some("account")); + // `account` is reported by the catalog, so it is named there and the + // physical column it lands in is not offered a second field. + assert_eq!(row("account").alias_source.as_deref(), Some("account")); + assert_eq!(row("account").alias, "ucet"); + assert!(row("account_id").alias_source.is_none()); assert!(row("account_id").tags.contains(&"written as ucet".to_string())); assert!( row("accounting").alias_source.is_none(), diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 722ace2d..bf11d6e0 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -126,7 +126,8 @@ mod tests { } /// The columns an ACCOUNTING row generates get a name field of their own, - /// which is what makes them aliasable at creation time. + /// which is what makes them aliasable at creation time — in a section of + /// their own, not in the column list, which is there to be read. #[test] fn generated_accounting_columns_get_an_alias_field() { let mut page = page(); @@ -151,6 +152,38 @@ mod tests { ); } assert!(html.contains(r#"name="generated_alias_names""#)); + // The column list itself carries no input at all. + let list = html + .split("

Columns") + .nth(1) + .and_then(|rest| rest.split("").next()) + .expect("the column list should be rendered"); + assert!(!list.contains("Rename generated columns")); + } + + /// A rename is reported where the column is listed, so the list still says + /// what the table will look like without the section being opened. + #[test] + fn a_renamed_generated_column_says_so_in_the_column_list() { + let mut page = page(); + page.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(), + }); + page.draft.generated_aliases = vec![crate::pages::add_table::draft::GeneratedAlias { + source: "debit".to_string(), + alias: "md".to_string(), + }]; + + let html = render_builder(&page); + + assert!(html.contains(r#"renamed to md"#), "{html}"); + assert!(html.contains(r#"name="generated_alias_names" value="md""#)); } #[test] diff --git a/web/src/schema/mod.rs b/web/src/schema/mod.rs index 5298cee3..62837106 100644 --- a/web/src/schema/mod.rs +++ b/web/src/schema/mod.rs @@ -1095,6 +1095,9 @@ pub(crate) mod tests { generated_column("tax_point_date", "date", false), generated_column("debit", "money", true), generated_column("credit", "money", true), + // The foreign key to the profile's accounts, which the + // backend reports with the rest of them. + generated_column("account", "link(accounts)", false), ], ..compound("accounting") }, @@ -1586,11 +1589,11 @@ pub(crate) mod tests { .iter() .map(|generated| generated.name.as_str()) .collect::>(), - ["name", "tax_point_date", "debit", "credit"] + ["name", "tax_point_date", "debit", "credit", "account"] ); draft.add_from_inputs().unwrap(); - assert_eq!(draft.generated_columns_of(0).len(), 4); + assert_eq!(draft.generated_columns_of(0).len(), 5); // DEBIT and CREDIT are the two kept in the declared currency. assert_eq!( draft diff --git a/web/static/app.css b/web/static/app.css index 6ff70a4b..4526e390 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -180,6 +180,17 @@ .compound-note li { padding: 1px 0; } .compound-note .hint { font-size: 12px; } + /* Renaming what a compound row generates: closed by default, because the + backend's names are the answer most tables keep. */ + .rename-generated > summary { font-size: 15px; color: #33415c; cursor: pointer; } + .rename-generated > summary::marker { color: #8a94a6; } + .rename-generated > .hint { margin: 8px 0 0; font-size: 13px; } + .rename-list { margin: 12px 0 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px; } + .rename-list li { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; } + .rename-list li > code { flex: none; } + .rename-list li > .hint { flex: none; font-size: 12px; } + .rename-list input { flex: 1; min-width: 90px; } + .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 9ef48686..fc13bf95 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -238,7 +238,7 @@
  • account_id system column — the account each row is posted to, written as account
  • {% endif %} -

    Each of these can be given a name of your own in the column list once the row is added; leave a field empty to keep the name shown here.

    +

    Each of these can be given a name of your own under Rename generated columns once the row is added; leave a field empty to keep the name shown here.

    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 %} @@ -279,17 +279,9 @@ {{ row.name }} - {# - A generated column is named by the backend, so it cannot be - named in the request that creates the table — but the name is a - display name, and an alias here is applied as a rename as soon - as the table exists. Leaving it empty keeps the backend's name. - #} - {% if let Some(source) = row.alias_source %} - - - {% endif %} + {# The list only reports a rename; it is asked for below, so that + this column stays something to read. #} + {% if !row.alias.is_empty() %}renamed to {{ row.alias }}{% endif %} {{ row.data_type }} @@ -334,6 +326,36 @@ {% endfor %} +{# + Renaming what a definition row generates. A generated column is named by the + backend, so it cannot be named in the request that creates the table — but the + name is a display name, and an alias here is applied as a rename as soon as + the table exists. Leaving a field empty keeps the backend's name. + + It is a closed `
    `, so the default — the backend's names — costs + nothing to read past. Fields inside a closed `
    ` are still part of the + form, so the aliases travel with every builder post either way. +#} +{% if !page.alias_rows().is_empty() %} +
    +
    + Rename generated columns +

    These columns come from the definition rows above. Give one a name of your own, or leave it empty to keep the name shown.

    +
      + {% for row in page.alias_rows() %} +
    • + {{ row.source }} + {{ row.data_type }} + + +
    • + {% endfor %} +
    +
    +
    +{% endif %} +
    {% for table in page.draft.relation_tables %}