diff --git a/common/proto/exchange_rates.proto b/common/proto/exchange_rates.proto index 72ecd709..3234b96e 100644 --- a/common/proto/exchange_rates.proto +++ b/common/proto/exchange_rates.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package komp_ac.exchange_rates; +import "common.proto"; + // Selects WHICH DATE supplies the rate; this is independent of where the rate // comes from. These are not four user-facing choices: UNSPECIFIED is protobuf's // zero-value sentinel and is rejected when an explicit selection is supplied. @@ -67,6 +69,10 @@ message ExchangeRateSelection { } service ExchangeRateService { + // Lists the direct-rate providers compiled into and registered by this + // server instance. Clients use these stable IDs when configuring profiles. + rpc ListRateSources(komp_ac.common.Empty) + returns (ListRateSourcesResponse); rpc PreviewDirectConversion(PreviewDirectConversionRequest) returns (PreviewDirectConversionResponse); rpc ListConversionEvidence(ListConversionEvidenceRequest) @@ -77,6 +83,14 @@ service ExchangeRateService { returns (ProfileForeignCurrency); } +message RateSourceProvider { + string id = 1; +} + +message ListRateSourcesResponse { + repeated RateSourceProvider sources = 1; +} + message PreviewDirectConversionRequest { string original_amount = 1; string original_currency = 2; diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 076a1873..31a9fd16 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -443,6 +443,11 @@ message TableDetail { // requested profile when a global table is being listed. See // ProfileTreeResponse.Table.profile_name. string profile_name = 11; + + // True when the physical table contains at least one row, including a + // soft-deleted row. Populated tables are append-only: PutTableDefinition is + // no longer available, while AddTableColumns remains available. + bool has_data = 12; } // Server-owned behavior for one logical column returned in table details. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index f45b2ca7..a71ec3ae 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.exchange_rates.rs b/common/src/proto/komp_ac.exchange_rates.rs index d3d48a46..64ed3319 100644 --- a/common/src/proto/komp_ac.exchange_rates.rs +++ b/common/src/proto/komp_ac.exchange_rates.rs @@ -18,6 +18,16 @@ pub struct ExchangeRateSelection { pub rate_source_id: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RateSourceProvider { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListRateSourcesResponse { + #[prost(message, repeated, tag = "1")] + pub sources: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PreviewDirectConversionRequest { #[prost(string, tag = "1")] pub original_amount: ::prost::alloc::string::String, @@ -408,6 +418,37 @@ pub mod exchange_rate_service_client { self.inner = self.inner.max_encoding_message_size(limit); self } + /// Lists the direct-rate providers compiled into and registered by this + /// server instance. Clients use these stable IDs when configuring profiles. + pub async fn list_rate_sources( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.exchange_rates.ExchangeRateService/ListRateSources", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.exchange_rates.ExchangeRateService", + "ListRateSources", + ), + ); + self.inner.unary(req, path, codec).await + } pub async fn preview_direct_conversion( &mut self, request: impl tonic::IntoRequest, @@ -541,6 +582,15 @@ pub mod exchange_rate_service_server { /// Generated trait containing gRPC methods that should be implemented for use with ExchangeRateServiceServer. #[async_trait] pub trait ExchangeRateService: std::marker::Send + std::marker::Sync + 'static { + /// Lists the direct-rate providers compiled into and registered by this + /// server instance. Clients use these stable IDs when configuring profiles. + async fn list_rate_sources( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn preview_direct_conversion( &self, request: tonic::Request, @@ -646,6 +696,55 @@ pub mod exchange_rate_service_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { + "/komp_ac.exchange_rates.ExchangeRateService/ListRateSources" => { + #[allow(non_camel_case_types)] + struct ListRateSourcesSvc(pub Arc); + impl< + T: ExchangeRateService, + > tonic::server::UnaryService + for ListRateSourcesSvc { + type Response = super::ListRateSourcesResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_rate_sources( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListRateSourcesSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/komp_ac.exchange_rates.ExchangeRateService/PreviewDirectConversion" => { #[allow(non_camel_case_types)] struct PreviewDirectConversionSvc( diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 2d84a993..7a498dba 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -458,6 +458,11 @@ pub struct TableDetail { /// ProfileTreeResponse.Table.profile_name. #[prost(string, tag = "11")] pub profile_name: ::prost::alloc::string::String, + /// True when the physical table contains at least one row, including a + /// soft-deleted row. Populated tables are append-only: PutTableDefinition is + /// no longer available, while AddTableColumns remains available. + #[prost(bool, tag = "12")] + pub has_data: bool, } /// Server-owned behavior for one logical column returned in table details. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/server b/server index f559f57d..fe581213 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit f559f57d40b9b0b8821ff9a924b2392903302813 +Subproject commit fe58121374af694f5d1cea0a6d1e82bad8511660 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index f65757e6..b47a30c2 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -16,10 +16,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Backend-provided rate-source suggestions** — the add-table page calls + `ExchangeRateService.ListRateSources` and offers the returned provider IDs + through the rate-source autocomplete field; initial foreign currencies use + the same ISO-4217 autocomplete options as the accounting currency. - **Editable empty-table definitions** — the table-definition column page now calls `PutTableDefinition` to atomically remove selected columns and append replacements, with stable column identities and optimistic revision checks. - Add-only changes continue to work for populated tables. + It is clearly labelled “Adjust table” only while the table is empty; after + data is posted, the page switches to append-only “Add columns” mode. - **The shared profile is named by the backend** — `ProfileTreeResponse` gained `shared_profile_name`, and every page that browses the shared tables now reads the name from there instead of spelling `__global` itself. Catalog diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 2320c00f..d29b367d 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -161,12 +161,12 @@ admin-system-columns-label = Systémové sloupce · skryté před uživateli # --- Stránky definice tabulky (přidat sloupce / zobrazení / smazat / kopírovat / šablona / historie) td-add-columns-title = Přidat sloupce -td-adjust-columns-title = Upravit sloupce +td-adjust-table-title = Upravit tabulku td-presentation-title = Zobrazení sloupců td-add-columns-to = Přidat sloupce do td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání. -td-adjust-columns-of = Upravit sloupce tabulky -td-adjust-hint = Přidejte nové sloupce nebo označte existující k odebrání. Samotné přidání funguje i v tabulce s daty; odebrání nebo nahrazení vyžaduje prázdnou tabulku a uloží se atomicky. +td-adjust-table-of = Upravit tabulku +td-adjust-table-hint = Tato tabulka neobsahuje data, takže můžete přidat nové sloupce nebo označit existující k odebrání. Změna se uloží atomicky. td-existing-columns = Existující sloupce td-new-columns = Nové sloupce k přidání td-remove-column = Odebrat @@ -258,8 +258,8 @@ td-global-label = Globální — všechny profily td-tab-aria = akce s tabulkou td-tab-add-columns = Přidat sloupce td-tab-add-columns-sub = Připojit nové sloupce -td-tab-adjust-columns = Upravit sloupce -td-tab-adjust-columns-sub = Přidat, odebrat nebo nahradit +td-tab-adjust-table = Upravit tabulku +td-tab-adjust-table-sub = Přidat, odebrat nebo nahradit sloupce td-tab-presentation = Zobrazení td-tab-presentation-sub = Změnit aliasy a pořadí td-tab-delete = Smazat @@ -338,6 +338,7 @@ td-err-describe-column = Před přidáním popište alespoň jeden sloupec. td-err-describe-change = Označte sloupec k odebrání nebo připravte alespoň jeden sloupec k přidání. td-err-backend-no-columns = Backend nepřidal sloupce. td-err-backend-no-adjustment = Backend neupravil definici tabulky. +td-err-populated-append-only = Tato tabulka obsahuje data. Existující sloupce nelze odebrat; lze pouze přidat nové sloupce. td-err-choose-rename = Zadejte nový název sloupce. td-err-unknown-column = Tento sloupec už do této tabulky nepatří. Panel níže ukazuje tabulku, jaká je teď. td-err-backend-no-rename = Backend nepřejmenoval sloupec. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 249ddc62..134515b3 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -163,12 +163,12 @@ admin-system-columns-label = System columns · hidden from users # --- Table definition pages (add columns / presentation / delete / copy / template / history) - td-add-columns-title = Add columns -td-adjust-columns-title = Adjust columns +td-adjust-table-title = Adjust table td-presentation-title = Column presentation td-add-columns-to = Add columns to td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added. -td-adjust-columns-of = Adjust columns of -td-adjust-hint = Add new columns, or select existing columns to remove. Add-only changes also work on populated tables; removing or replacing columns requires an empty table and is saved atomically. +td-adjust-table-of = Adjust table +td-adjust-table-hint = This table has no data, so you can add new columns or select existing columns to remove. The change is saved atomically. td-existing-columns = Existing columns td-new-columns = New columns to add td-remove-column = Remove @@ -258,8 +258,8 @@ td-global-label = Global — all profiles td-tab-aria = table actions td-tab-add-columns = Add columns td-tab-add-columns-sub = Append new columns -td-tab-adjust-columns = Adjust columns -td-tab-adjust-columns-sub = Add, remove, or replace columns +td-tab-adjust-table = Adjust table +td-tab-adjust-table-sub = Add, remove, or replace columns td-tab-presentation = Presentation td-tab-presentation-sub = Change aliases and order td-tab-delete = Delete @@ -334,6 +334,7 @@ td-err-describe-column = Describe at least one column before adding. td-err-describe-change = Select a column to remove or stage at least one column to add. td-err-backend-no-columns = The backend did not add the columns. td-err-backend-no-adjustment = The backend did not adjust the table definition. +td-err-populated-append-only = This table contains data. Existing columns cannot be removed; only new columns can be added. td-err-choose-rename = Type a new name for the column. td-err-unknown-column = That column is not part of this table any more. The panel below is the table as it now is. td-err-backend-no-rename = The backend did not rename the column. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index dbb48c89..7f7a4859 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -161,12 +161,12 @@ admin-system-columns-label = Systémové stĺpce · skryté pred používateľmi # --- Stránky definície tabuľky (pridať stĺpce / zobrazenie / vymazať / kopírovať / šablóna / história) td-add-columns-title = Pridať stĺpce -td-adjust-columns-title = Upraviť stĺpce +td-adjust-table-title = Upraviť tabuľku td-presentation-title = Zobrazenie stĺpcov td-add-columns-to = Pridať stĺpce do td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní. -td-adjust-columns-of = Upraviť stĺpce tabuľky -td-adjust-hint = Pridajte nové stĺpce alebo označte existujúce na odstránenie. Samotné pridanie funguje aj v tabuľke s údajmi; odstránenie alebo nahradenie vyžaduje prázdnu tabuľku a uloží sa atómovo. +td-adjust-table-of = Upraviť tabuľku +td-adjust-table-hint = Táto tabuľka neobsahuje údaje, takže môžete pridať nové stĺpce alebo označiť existujúce na odstránenie. Zmena sa uloží atómovo. td-existing-columns = Existujúce stĺpce td-new-columns = Nové stĺpce na pridanie td-remove-column = Odstrániť @@ -258,8 +258,8 @@ td-global-label = Globálne — všetky profily td-tab-aria = akcie s tabuľkou td-tab-add-columns = Pridať stĺpce td-tab-add-columns-sub = Pripojiť nové stĺpce -td-tab-adjust-columns = Upraviť stĺpce -td-tab-adjust-columns-sub = Pridať, odstrániť alebo nahradiť +td-tab-adjust-table = Upraviť tabuľku +td-tab-adjust-table-sub = Pridať, odstrániť alebo nahradiť stĺpce td-tab-presentation = Zobrazenie td-tab-presentation-sub = Zmeniť aliasy a poradie td-tab-delete = Vymazať @@ -338,6 +338,7 @@ td-err-describe-column = Pred pridaním popíšte aspoň jeden stĺpec. td-err-describe-change = Označte stĺpec na odstránenie alebo pripravte aspoň jeden stĺpec na pridanie. td-err-backend-no-columns = Backend nepridal stĺpce. td-err-backend-no-adjustment = Backend neupravil definíciu tabuľky. +td-err-populated-append-only = Táto tabuľka obsahuje údaje. Existujúce stĺpce nemožno odstrániť; možno iba pridať nové stĺpce. td-err-choose-rename = Zadajte nový názov stĺpca. td-err-unknown-column = Tento stĺpec už do tejto tabuľky nepatrí. Panel nižšie ukazuje tabuľku, aká je teraz. td-err-backend-no-rename = Backend nepremenoval stĺpec. diff --git a/web/src/lib.rs b/web/src/lib.rs index f51dbe74..62baea50 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -88,7 +88,8 @@ mod definitions { use auth::auth_service_client::AuthServiceClient; use definitions::{ - ecb, table_definition::table_definition_client::TableDefinitionClient, + ecb, exchange_rates::exchange_rate_service_client::ExchangeRateServiceClient, + table_definition::table_definition_client::TableDefinitionClient, table_script::table_script_client::TableScriptClient, table_structure::table_structure_service_client::TableStructureServiceClient, table_validation::table_validation_service_client::TableValidationServiceClient, @@ -113,6 +114,7 @@ pub(crate) struct AppState { validations: TableValidationServiceClient, tables_data: TablesDataClient, ecb: EcbServiceClient, + exchange_rates: ExchangeRateServiceClient, /// The imports this process is running, which the import page polls. Not a /// client: an import outlives the request that started it, so where it has /// got to has to live somewhere both the task and the next request can see. @@ -149,6 +151,7 @@ pub async fn serve() -> Result<(), Box> { validations: TableValidationServiceClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()), ecb: EcbServiceClient::new(channel.clone()), + exchange_rates: ExchangeRateServiceClient::new(channel.clone()), structures: TableStructureServiceClient::new(channel), imports: Default::default(), }; @@ -215,6 +218,7 @@ mod tests { validations: TableValidationServiceClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()), ecb: EcbServiceClient::new(channel.clone()), + exchange_rates: ExchangeRateServiceClient::new(channel.clone()), structures: TableStructureServiceClient::new(channel), imports: Default::default(), }) diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 0b76930f..a5f38f25 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -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, diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 2564bf0d..17292719 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -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, diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 8870f363..1a286f5e 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -198,6 +198,8 @@ impl BuilderForm { pub(crate) struct AddTablePageState { pub nav: crate::ui::Nav, pub profiles: Vec, + /// Provider IDs returned by ExchangeRateService.ListRateSources. + pub rate_sources: Vec, /// 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, diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index a64475f5..bb8dea0f 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -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#""#)); assert!(html.contains(r#"