exchange broker options

This commit is contained in:
Filipriec
2026-08-24 07:03:57 +02:00
parent 7109be803b
commit 2e95201a71
25 changed files with 246 additions and 39 deletions

View File

@@ -40,6 +40,19 @@ pub(crate) async fn load_page(
return Err(LoadError::Forbidden);
}
let mut exchange_rates = state.exchange_rates.clone();
let rate_sources = exchange_rates
.list_rate_sources(
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.sources
.into_iter()
.map(|source| source.id)
.collect();
let mut definitions = state.definitions;
// What a column may be is the backend's to say; the picker and every rule
// the draft applies are read from this.
@@ -135,6 +148,7 @@ pub(crate) async fn load_page(
.into_iter()
.map(|profile| profile.name)
.collect(),
rate_sources,
draft,
status,
error,

View File

@@ -249,6 +249,7 @@ mod tests {
nav: crate::ui::Nav::default(),
shared_profile: "__global".to_string(),
profiles: vec!["billing".to_string()],
rate_sources: vec!["ecb".to_string()],
draft,
status: None,
error: None,

View File

@@ -198,6 +198,8 @@ impl BuilderForm {
pub(crate) struct AddTablePageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
/// Provider IDs returned by ExchangeRateService.ListRateSources.
pub rate_sources: Vec<String>,
/// The profile a global table lands in, as the profile tree reports it.
/// The builder posts it as the created table's scope.
pub shared_profile: String,
@@ -511,6 +513,7 @@ mod tests {
nav: crate::ui::Nav::default(),
shared_profile: "__global".to_string(),
profiles: Vec::new(),
rate_sources: vec!["ecb".to_string()],
draft: posted_form().to_draft(),
status: None,
error: None,
@@ -597,6 +600,7 @@ mod tests {
nav: crate::ui::Nav::default(),
shared_profile: "__global".to_string(),
profiles: Vec::new(),
rate_sources: vec!["ecb".to_string()],
draft,
status: None,
error: None,

View File

@@ -111,6 +111,7 @@ mod tests {
nav: Nav::default(),
shared_profile: "__global".to_string(),
profiles: vec!["billing".to_string()],
rate_sources: vec!["ecb".to_string()],
draft,
status: None,
error: None,
@@ -221,13 +222,15 @@ mod tests {
}
#[test]
fn the_page_offers_the_new_profile_option_and_the_currency_list() {
fn the_page_offers_backend_vocabularies_for_profile_setup() {
let html = render_page(&page());
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"value="__new__""#));
assert!(html.contains(r#"<datalist id="currency-codes">"#));
assert!(html.contains(r#"<option value="EUR">"#));
assert!(html.contains(r#"<datalist id="rate-source-ids">"#));
assert!(html.contains(r#"<option value="ecb">"#));
}
#[test]
@@ -373,7 +376,16 @@ mod tests {
state.draft.creating_new_profile = true;
let html = render_builder(&state);
assert!(html.contains(r#"name="profile_name_input""#));
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
assert!(html.contains(r#"name="accounting_currency""#));
assert!(html.contains(r#"list="currency-codes""#));
assert!(html.contains(r#"name="rate_source_id""#));
assert!(html.contains(r#"list="rate-source-ids""#));
let foreign_currency_input = html
.split(r#"name="foreign_currencies""#)
.nth(1)
.and_then(|rest| rest.split('>').next())
.expect("the initial foreign-currency input should be rendered");
assert!(foreign_currency_input.contains(r#"list="currency-codes""#));
}
/// The dialog only exists when there is a failure, and it carries the same

View File

@@ -202,6 +202,7 @@ pub(crate) async fn load_page(
.map(|table| TableDetailView {
id: table.id,
row_version: table.row_version,
has_data: table.has_data,
columns: table
.columns
.iter()

View File

@@ -215,8 +215,8 @@ pub(crate) async fn update_columns(
}
}
/// POST /admin/tables/columns/add — append columns, or atomically replace part
/// of an empty table through PutTableDefinition.
/// POST /admin/tables/columns/add — atomically adjust an empty table, or append
/// columns once the table contains data.
pub(crate) async fn add_columns(
State(state): State<AppState>,
headers: HeaderMap,
@@ -275,13 +275,6 @@ pub(crate) async fn add_columns(
return refuse(state, headers, inputs, Page::AddColumns, message).await;
}
// Keep the old append capability for populated tables. Put is used only
// when a removal was requested, because that operation deliberately
// refuses every table which has ever stored a row.
if inputs.remove_column_ids.is_empty() {
return append_columns(state, headers, inputs, catalog, definitions).await;
}
let current = match load_page(state.clone(), &headers, inputs.clone()).await {
Ok(page) => page.detail,
Err(error) => return load_error_response(&headers, error),
@@ -290,6 +283,19 @@ pub(crate) async fn add_columns(
let message = tr!(Locale::from_headers(&headers), "td-err-select-table-first");
return refuse(state, headers, inputs, Page::AddColumns, message).await;
};
// The read model makes the mode explicit: an empty table is adjusted with
// PutTableDefinition, while a populated table exposes append-only changes.
// Re-checking here also keeps crafted posts from smuggling removals through
// the append-only UI.
if current.has_data {
if !inputs.remove_column_ids.is_empty() {
let message = tr!(Locale::from_headers(&headers), "td-err-populated-append-only");
return refuse(state, headers, inputs, Page::AddColumns, message).await;
}
return append_columns(state, headers, inputs, catalog, definitions).await;
}
let remove_column_ids = match current.expanded_removal_ids(&inputs.remove_column_ids) {
Ok(ids) => ids,
Err(()) => {

View File

@@ -85,6 +85,7 @@ impl TableSummary {
pub(crate) struct TableDetailView {
pub id: i64,
pub row_version: i64,
pub has_data: bool,
pub row_display_columns: Vec<String>,
pub columns: Vec<DetailColumn>,
pub scripts: Vec<ScriptView>,
@@ -377,6 +378,12 @@ pub(crate) struct TableDefinitionPageState {
}
impl TableDefinitionPageState {
/// Empty dynamic tables may be reshaped atomically. Once data exists, the
/// backend deliberately exposes only append-only column changes.
pub(crate) fn can_adjust_definition(&self) -> bool {
self.table_is_writable() && self.detail.as_ref().is_some_and(|detail| !detail.has_data)
}
pub(crate) fn column_is_selected_for_removal(&self, column_id: &i64) -> bool {
self.remove_column_ids.contains(column_id)
}
@@ -486,6 +493,7 @@ mod tests {
let detail = TableDetailView {
id: 1,
row_version: 1,
has_data: false,
row_display_columns: Vec::new(),
scripts: Vec::new(),
columns: vec![

View File

@@ -273,6 +273,7 @@ mod tests {
detail: Some(TableDetailView {
id: 7,
row_version: 1,
has_data: false,
row_display_columns: vec!["number".to_string()],
scripts: Vec::new(),
columns: vec![DetailColumn {
@@ -456,7 +457,7 @@ mod tests {
assert!(add_html.contains(r#"id="column-form""#), "{add_html}");
assert!(add_html.contains(r#"name="expected_row_version" value="1""#), "{add_html}");
assert!(add_html.contains(r#"name="remove_column_ids" value="1""#), "{add_html}");
assert!(add_html.contains("Adjust columns"), "{add_html}");
assert!(add_html.contains("Adjust table"), "{add_html}");
assert!(!add_html.contains(r#"name="alias""#), "{add_html}");
let presentation_html = render_presentation_page(&page());
@@ -464,6 +465,19 @@ mod tests {
assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}");
}
#[test]
fn a_populated_table_offers_append_only_columns() {
let mut state = page();
state.detail.as_mut().unwrap().has_data = true;
let html = render_add_columns_page(&state);
assert!(html.contains("Add columns"), "{html}");
assert!(html.contains("Columns are appended"), "{html}");
assert!(!html.contains(r#"name="remove_column_ids""#), "{html}");
assert!(!html.contains("Save table definition"), "{html}");
}
/// The profile-wide pages need only a profile, and say so by still
/// rendering with no table selected.
#[test]