exchange broker options
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(()) => {
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user