web aliasing of accounting and others

This commit is contained in:
Priec
2026-08-13 19:05:41 +02:00
parent 6c15fcedc8
commit 8662b00ccf
7 changed files with 161 additions and 33 deletions

View File

@@ -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<String> = 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",
]

View File

@@ -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<AliasRow> {
let mut rows: Vec<AliasRow> = 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<RowDisplayCandidate> {
@@ -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(),

View File

@@ -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("<h2>Columns")
.nth(1)
.and_then(|rest| rest.split("</table>").next())
.expect("the column list should be rendered");
assert!(!list.contains("<input"), "{list}");
assert!(html.contains("<summary>Rename generated columns</summary>"));
}
/// 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#"<span class="tag">renamed to md</span>"#), "{html}");
assert!(html.contains(r#"name="generated_alias_names" value="md""#));
}
#[test]

View File

@@ -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::<Vec<_>>(),
["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

View File

@@ -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; }

View File

@@ -238,7 +238,7 @@
<li><code>account_id</code> <span class="hint">system column — the account each row is posted to, written as <code>account</code></span></li>
{% endif %}
</ul>
<p class="hint">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.</p>
<p class="hint">Each of these can be given a name of your own under <em>Rename generated columns</em> once the row is added; leave a field empty to keep the name shown here.</p>
<p class="hint">These names are reserved: no column of your own may use them, and the table may hold only one <code>{{ page.draft.columns.pending_compound_name() }}</code>. They land where this row sits in the column list, and can be moved with it.</p>
</div>
{% endif %}
@@ -279,17 +279,9 @@
</td>
<td>
<code>{{ row.name }}</code>
{#
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 %}
<input type="hidden" name="generated_alias_sources" value="{{ source }}">
<input name="generated_alias_names" value="{{ row.alias }}"
aria-label="Alias for {{ source }}" placeholder="alias for {{ 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() %}<span class="tag">renamed to {{ row.alias }}</span>{% endif %}
</td>
<td>{{ row.data_type }}</td>
<td>
@@ -334,6 +326,36 @@
{% endfor %}
</section>
{#
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 `<details>`, so the default — the backend's names — costs
nothing to read past. Fields inside a closed `<details>` are still part of the
form, so the aliases travel with every builder post either way.
#}
{% if !page.alias_rows().is_empty() %}
<section class="builder-section">
<details class="rename-generated">
<summary>Rename generated columns</summary>
<p class="hint">These columns come from the definition rows above. Give one a name of your own, or leave it empty to keep the name shown.</p>
<ul class="rename-list">
{% for row in page.alias_rows() %}
<li>
<code>{{ row.source }}</code>
<span class="hint">{{ row.data_type }}</span>
<input type="hidden" name="generated_alias_sources" value="{{ row.source }}">
<input name="generated_alias_names" value="{{ row.alias }}"
aria-label="Rename {{ row.source }}" placeholder="{{ row.source }}">
</li>
{% endfor %}
</ul>
</details>
</section>
{% endif %}
<section class="builder-section">
{% for table in page.draft.relation_tables %}
<input type="hidden" name="relation_tables" value="{{ table }}">