exchange broker options
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -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<RateSourceProvider>,
|
||||
}
|
||||
#[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<super::super::common::Empty>,
|
||||
) -> std::result::Result<
|
||||
tonic::Response<super::ListRateSourcesResponse>,
|
||||
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<super::PreviewDirectConversionRequest>,
|
||||
@@ -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<super::super::common::Empty>,
|
||||
) -> std::result::Result<
|
||||
tonic::Response<super::ListRateSourcesResponse>,
|
||||
tonic::Status,
|
||||
>;
|
||||
async fn preview_direct_conversion(
|
||||
&self,
|
||||
request: tonic::Request<super::PreviewDirectConversionRequest>,
|
||||
@@ -646,6 +696,55 @@ pub mod exchange_rate_service_server {
|
||||
}
|
||||
fn call(&mut self, req: http::Request<B>) -> Self::Future {
|
||||
match req.uri().path() {
|
||||
"/komp_ac.exchange_rates.ExchangeRateService/ListRateSources" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct ListRateSourcesSvc<T: ExchangeRateService>(pub Arc<T>);
|
||||
impl<
|
||||
T: ExchangeRateService,
|
||||
> tonic::server::UnaryService<super::super::common::Empty>
|
||||
for ListRateSourcesSvc<T> {
|
||||
type Response = super::ListRateSourcesResponse;
|
||||
type Future = BoxFuture<
|
||||
tonic::Response<Self::Response>,
|
||||
tonic::Status,
|
||||
>;
|
||||
fn call(
|
||||
&mut self,
|
||||
request: tonic::Request<super::super::common::Empty>,
|
||||
) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move {
|
||||
<T as ExchangeRateService>::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<T: ExchangeRateService>(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
2
server
2
server
Submodule server updated: f559f57d40...fe58121374
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Channel>,
|
||||
tables_data: TablesDataClient<Channel>,
|
||||
ecb: EcbServiceClient<Channel>,
|
||||
exchange_rates: ExchangeRateServiceClient<Channel>,
|
||||
/// 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<dyn std::error::Error + Send + Sync>> {
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -21,4 +21,7 @@
|
||||
<datalist id="currency-codes">
|
||||
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
<datalist id="rate-source-ids">
|
||||
{% for source in page.rate_sources %}<option value="{{ source }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endblock %}
|
||||
|
||||
@@ -85,11 +85,11 @@
|
||||
</label>
|
||||
<label>Rate source
|
||||
<input name="rate_source_id" value="{{ page.draft.rate_source_id }}"
|
||||
placeholder="provider-id">
|
||||
list="rate-source-ids" placeholder="provider-id">
|
||||
</label>
|
||||
<label>Initial foreign currencies
|
||||
<input name="foreign_currencies" value="{{ page.draft.foreign_currencies }}"
|
||||
placeholder="CZK, USD">
|
||||
list="currency-codes" placeholder="CZK, USD">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# GET /admin/tables/columns/add — crate::pages::admin::table_definition::ui::AddColumnsPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}{{ nav.tr("td-adjust-columns-title") }}{% endblock %}
|
||||
{% block title %}{% if page.can_adjust_definition() %}{{ nav.tr("td-adjust-table-title") }}{% else %}{{ nav.tr("td-add-columns-title") }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
|
||||
{% if page.table_is_writable() %}
|
||||
<section class="panel">
|
||||
<h2>{{ nav.tr("td-adjust-columns-of") }} <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">{{ nav.tr("td-adjust-hint") }}</p>
|
||||
{% if page.can_adjust_definition() %}
|
||||
<h2>{{ nav.tr("td-adjust-table-of") }} <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">{{ nav.tr("td-adjust-table-hint") }}</p>
|
||||
{% else %}
|
||||
<h2>{{ nav.tr("td-add-columns-to") }} <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">{{ nav.tr("td-append-hint") }}</p>
|
||||
{% endif %}
|
||||
<form id="column-form"
|
||||
hx-post="/admin/tables/columns/add{{ page.selection.query() }}"
|
||||
hx-target="#table-panel" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
{% if let Some(detail) = page.detail %}
|
||||
{% if page.can_adjust_definition() %}{% if let Some(detail) = page.detail %}
|
||||
<input type="hidden" name="expected_row_version" value="{{ detail.row_version }}">
|
||||
<h3>{{ nav.tr("td-existing-columns") }}</h3>
|
||||
<table class="builder-table">
|
||||
@@ -34,7 +39,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endif %}{% endif %}
|
||||
<h3>{{ nav.tr("td-new-columns") }}</h3>
|
||||
<div id="column-panel">
|
||||
{% include "pages/admin/table_definition/column_panel.html" %}
|
||||
|
||||
@@ -216,5 +216,5 @@
|
||||
{% endfor %}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit">{{ nav.tr("td-save-definition") }}</button>
|
||||
<button type="submit">{% if page.can_adjust_definition() %}{{ nav.tr("td-save-definition") }}{% else %}{{ nav.tr("td-add-columns-title") }}{% endif %}</button>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,11 @@
|
||||
{% if page.selection.has_table() %}
|
||||
{% if page.table_is_writable() %}
|
||||
<a href="/admin/tables/columns/add{{ page.selection.query() }}" class="tab {% if page.is("add-columns") %}selected{% endif %}" {% if page.is("add-columns") %}aria-current="page"{% endif %}>
|
||||
<span>{{ nav.tr("td-tab-adjust-columns") }}</span><small>{{ nav.tr("td-tab-adjust-columns-sub") }}</small>
|
||||
{% if page.can_adjust_definition() %}
|
||||
<span>{{ nav.tr("td-tab-adjust-table") }}</span><small>{{ nav.tr("td-tab-adjust-table-sub") }}</small>
|
||||
{% else %}
|
||||
<span>{{ nav.tr("td-tab-add-columns") }}</span><small>{{ nav.tr("td-tab-add-columns-sub") }}</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="/admin/tables/presentation{{ page.selection.query() }}" class="tab {% if page.is("presentation") %}selected{% endif %}" {% if page.is("presentation") %}aria-current="page"{% endif %}>
|
||||
<span>{{ nav.tr("td-tab-presentation") }}</span><small>{{ nav.tr("td-tab-presentation-sub") }}</small>
|
||||
|
||||
Reference in New Issue
Block a user