From dcc07ee7087dbfd78bc15a474000f1716f686d5c Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 11 Aug 2026 21:07:21 +0200 Subject: [PATCH] versioning of FK for archiving --- client | 2 +- common/proto/table_definition.proto | 9 + common/proto/tables_data.proto | 52 +++ common/src/grpc_error.rs | 1 + common/src/proto/descriptor.bin | Bin 158173 -> 162092 bytes common/src/proto/komp_ac.table_definition.rs | 9 + common/src/proto/komp_ac.tables_data.rs | 410 +++++++++++++++++++ server | 2 +- web/src/pages/add_table/draft.rs | 14 +- web/src/pages/add_table/loader.rs | 23 +- web/src/pages/add_table/logic.rs | 3 +- web/src/pages/add_table/state.rs | 3 + web/templates/pages/add_table/builder.html | 12 + 13 files changed, 529 insertions(+), 11 deletions(-) diff --git a/client b/client index 1e3c9de4..a330f747 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 1e3c9de431a28de635ef4caac5c7ea69835b0822 +Subproject commit a330f747090baf32825ae6a7caf5f8d95de6d863 diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 3cd4b0a6..35a71436 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -83,6 +83,11 @@ message PostTableDefinitionRequest { // to individual MONEY-column currencies: tables may hold money in any currency, and amounts convert // to this one when they reach the ledger. string accounting_currency = 8; + + // When true, the table is stored once in the global physical schema and is + // visible from every profile. profile_name and accounting_currency are ignored. + bool global = 9; + } // Defines the input for explicitly creating tables backed by one invoice @@ -237,6 +242,9 @@ message ProfileTreeResponse { // "dynamic" for user-defined tables, "system" for backend-managed tables. string table_kind = 5; + + // True when this table is shared by every profile. + bool global = 6; } // Profile (schema) entry. @@ -313,6 +321,7 @@ message TableDetail { repeated string row_display_columns = 6; map column_behaviors = 7; string table_kind = 8; + bool global = 9; } // Server-owned behavior for one logical column returned in table details. diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index 128970b2..eb855980 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -47,6 +47,18 @@ service TablesData { // - Updates the row and returns the id; queues search indexing (best effort) rpc PutTableData(PutTableDataRequest) returns (PutTableDataResponse); + // Performs a PUT after the user explicitly accepted its cross-profile impact. + // This is meaningful only for global tables; ordinary tables behave exactly + // like PutTableData. + rpc PutTableDataConfirmed(PutTableDataRequest) returns (PutTableDataResponse); + + // Lists the other profiles whose rows currently point at this global row. + rpc GetGlobalTableUpdateImpact(GlobalTableUpdateImpactRequest) returns (GlobalTableUpdateImpactResponse); + + // Snapshots the current version of a global row and advances its version. + // Existing references remain immutable; new references use the new version. + rpc ArchiveGlobalTableData(ArchiveGlobalTableDataRequest) returns (ArchiveGlobalTableDataResponse); + // Soft-delete a single record (sets deleted = true) if it exists and is not already deleted. // // Behavior: @@ -68,6 +80,9 @@ service TablesData { // - If the physical table is missing but the definition exists, returns INTERNAL rpc GetTableData(GetTableDataRequest) returns (GetTableDataResponse); + // Fetches one exact version of a global row. + rpc GetGlobalTableDataVersion(GetGlobalTableDataVersionRequest) returns (GetTableDataResponse); + // Count non-deleted rows in a table. // // Behavior: @@ -214,6 +229,32 @@ message PutTableDataResponse { int64 row_revision = 4; } +message GlobalTableUpdateImpactRequest { + string profile_name = 1; + string table_name = 2; + int64 id = 3; +} + +message GlobalTableUpdateImpactResponse { + repeated string affected_profiles = 1; +} + +message ArchiveGlobalTableDataRequest { + string profile_name = 1; + string table_name = 2; + int64 id = 3; + int64 expected_revision = 4; +} + +message ArchiveGlobalTableDataResponse { + bool success = 1; + int64 archived_version = 2; + string archived_at = 3; + repeated string affected_profiles = 4; + int64 current_version = 5; + int64 row_revision = 6; +} + // Soft-delete a single row. message DeleteTableDataRequest { // Required. Profile (schema) name. @@ -245,6 +286,14 @@ message GetTableDataRequest { // Required. Id of the row to fetch. int64 id = 3; + +} + +message GetGlobalTableDataVersionRequest { + string profile_name = 1; + string table_name = 2; + int64 id = 3; + int64 version = 4; } // Row payload: all columns returned as strings. @@ -264,6 +313,9 @@ message GetTableDataResponse { // identified by its id alone. repeated string row_display_values = 2; repeated string row_display_columns = 3; + + // Version paired with each global-link column in this row. + map link_versions = 4; } // Count non-deleted rows. diff --git a/common/src/grpc_error.rs b/common/src/grpc_error.rs index 0a905dfa..80245001 100644 --- a/common/src/grpc_error.rs +++ b/common/src/grpc_error.rs @@ -2,3 +2,4 @@ pub const ERROR_REASON_METADATA_KEY: &str = "komp-ac-error-reason"; pub const COMPUTED_VALUE_MISMATCH_REASON: &str = "computed-value-mismatch"; pub const ROW_STALE_REASON: &str = "row-stale"; pub const ROW_ID_CONFLICT_REASON: &str = "row-id-conflict"; +pub const GLOBAL_UPDATE_CONFIRMATION_REASON: &str = "global-update-confirmation"; diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 41529625c3c97a21763d2b4ffbecce7f3cda18a3..b102d93328d9cea2bcf4cd7a32ad29ad17f16e28 100644 GIT binary patch delta 12361 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 T<>Jw1JRKj4R902YuPFV0NxW_G diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 257ebf27..c8c6d77f 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -37,6 +37,10 @@ pub struct PostTableDefinitionRequest { /// to this one when they reach the ledger. #[prost(string, tag = "8")] pub accounting_currency: ::prost::alloc::string::String, + /// When true, the table is stored once in the global physical schema and is + /// visible from every profile. profile_name and accounting_currency are ignored. + #[prost(bool, tag = "9")] + pub global: bool, } /// Defines the input for explicitly creating tables backed by one invoice /// template. typst_source must contain exactly one field declaration: @@ -215,6 +219,9 @@ pub mod profile_tree_response { /// "dynamic" for user-defined tables, "system" for backend-managed tables. #[prost(string, tag = "5")] pub table_kind: ::prost::alloc::string::String, + /// True when this table is shared by every profile. + #[prost(bool, tag = "6")] + pub global: bool, } /// Profile (schema) entry. #[derive(Clone, PartialEq, ::prost::Message)] @@ -324,6 +331,8 @@ pub struct TableDetail { >, #[prost(string, tag = "8")] pub table_kind: ::prost::alloc::string::String, + #[prost(bool, tag = "9")] + pub global: bool, } /// Server-owned behavior for one logical column returned in table details. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index 627c388a..161d1d8b 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -149,6 +149,46 @@ pub struct PutTableDataResponse { #[prost(int64, tag = "4")] pub row_revision: i64, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GlobalTableUpdateImpactRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub id: i64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GlobalTableUpdateImpactResponse { + #[prost(string, repeated, tag = "1")] + pub affected_profiles: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArchiveGlobalTableDataRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub id: i64, + #[prost(int64, tag = "4")] + pub expected_revision: i64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArchiveGlobalTableDataResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(int64, tag = "2")] + pub archived_version: i64, + #[prost(string, tag = "3")] + pub archived_at: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "4")] + pub affected_profiles: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(int64, tag = "5")] + pub current_version: i64, + #[prost(int64, tag = "6")] + pub row_revision: i64, +} /// Soft-delete a single row. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteTableDataRequest { @@ -185,6 +225,17 @@ pub struct GetTableDataRequest { #[prost(int64, tag = "3")] pub id: i64, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetGlobalTableDataVersionRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub id: i64, + #[prost(int64, tag = "4")] + pub version: i64, +} /// Row payload: all columns returned as strings. #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetTableDataResponse { @@ -209,6 +260,9 @@ pub struct GetTableDataResponse { pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(string, repeated, tag = "3")] pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Version paired with each global-link column in this row. + #[prost(map = "string, int64", tag = "4")] + pub link_versions: ::std::collections::HashMap<::prost::alloc::string::String, i64>, } /// Count non-deleted rows. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -443,6 +497,99 @@ pub mod tables_data_client { ); self.inner.unary(req, path, codec).await } + /// Performs a PUT after the user explicitly accepted its cross-profile impact. + /// This is meaningful only for global tables; ordinary tables behave exactly + /// like PutTableData. + pub async fn put_table_data_confirmed( + &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.tables_data.TablesData/PutTableDataConfirmed", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.tables_data.TablesData", + "PutTableDataConfirmed", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Lists the other profiles whose rows currently point at this global row. + pub async fn get_global_table_update_impact( + &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.tables_data.TablesData/GetGlobalTableUpdateImpact", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.tables_data.TablesData", + "GetGlobalTableUpdateImpact", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Snapshots the current version of a global row and advances its version. + /// Existing references remain immutable; new references use the new version. + pub async fn archive_global_table_data( + &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.tables_data.TablesData/ArchiveGlobalTableData", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.tables_data.TablesData", + "ArchiveGlobalTableData", + ), + ); + self.inner.unary(req, path, codec).await + } /// Soft-delete a single record (sets deleted = true) if it exists and is not already deleted. /// /// Behavior: @@ -514,6 +661,36 @@ pub mod tables_data_client { ); self.inner.unary(req, path, codec).await } + /// Fetches one exact version of a global row. + pub async fn get_global_table_data_version( + &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.tables_data.TablesData/GetGlobalTableDataVersion", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.tables_data.TablesData", + "GetGlobalTableDataVersion", + ), + ); + self.inner.unary(req, path, codec).await + } /// Count non-deleted rows in a table. /// /// Behavior: @@ -655,6 +832,33 @@ pub mod tables_data_server { tonic::Response, tonic::Status, >; + /// Performs a PUT after the user explicitly accepted its cross-profile impact. + /// This is meaningful only for global tables; ordinary tables behave exactly + /// like PutTableData. + async fn put_table_data_confirmed( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Lists the other profiles whose rows currently point at this global row. + async fn get_global_table_update_impact( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Snapshots the current version of a global row and advances its version. + /// Existing references remain immutable; new references use the new version. + async fn archive_global_table_data( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Soft-delete a single record (sets deleted = true) if it exists and is not already deleted. /// /// Behavior: @@ -688,6 +892,14 @@ pub mod tables_data_server { tonic::Response, tonic::Status, >; + /// Fetches one exact version of a global row. + async fn get_global_table_data_version( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Count non-deleted rows in a table. /// /// Behavior: @@ -934,6 +1146,152 @@ pub mod tables_data_server { }; Box::pin(fut) } + "/komp_ac.tables_data.TablesData/PutTableDataConfirmed" => { + #[allow(non_camel_case_types)] + struct PutTableDataConfirmedSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for PutTableDataConfirmedSvc { + type Response = super::PutTableDataResponse; + 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 { + ::put_table_data_confirmed(&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 = PutTableDataConfirmedSvc(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.tables_data.TablesData/GetGlobalTableUpdateImpact" => { + #[allow(non_camel_case_types)] + struct GetGlobalTableUpdateImpactSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for GetGlobalTableUpdateImpactSvc { + type Response = super::GlobalTableUpdateImpactResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::GlobalTableUpdateImpactRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_global_table_update_impact( + &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 = GetGlobalTableUpdateImpactSvc(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.tables_data.TablesData/ArchiveGlobalTableData" => { + #[allow(non_camel_case_types)] + struct ArchiveGlobalTableDataSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for ArchiveGlobalTableDataSvc { + type Response = super::ArchiveGlobalTableDataResponse; + 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 { + ::archive_global_table_data( + &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 = ArchiveGlobalTableDataSvc(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.tables_data.TablesData/DeleteTableData" => { #[allow(non_camel_case_types)] struct DeleteTableDataSvc(pub Arc); @@ -1024,6 +1382,58 @@ pub mod tables_data_server { }; Box::pin(fut) } + "/komp_ac.tables_data.TablesData/GetGlobalTableDataVersion" => { + #[allow(non_camel_case_types)] + struct GetGlobalTableDataVersionSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService< + super::GetGlobalTableDataVersionRequest, + > for GetGlobalTableDataVersionSvc { + type Response = super::GetTableDataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::GetGlobalTableDataVersionRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_global_table_data_version( + &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 = GetGlobalTableDataVersionSvc(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.tables_data.TablesData/GetTableDataCount" => { #[allow(non_camel_case_types)] struct GetTableDataCountSvc(pub Arc); diff --git a/server b/server index 3bf72f51..ac0c12a7 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 3bf72f51a61614837c1a1703afca42e334c78511 +Subproject commit ac0c12a7ac86887fbaaefb2915d0b08ddd4493bb diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 863ffc52..84320c73 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -35,6 +35,7 @@ pub(crate) struct TableDraft { /// Profile name typed in when creating a new profile. pub profile_name_input: String, pub creating_new_profile: bool, + pub global: bool, pub accounting_currency: String, pub table_name: String, @@ -69,11 +70,11 @@ impl TableDraft { // ---- field visibility (the same rules the TUI canvas applies) -------- pub(crate) fn show_profile_name_input(&self) -> bool { - self.creating_new_profile + self.creating_new_profile && !self.global } pub(crate) fn show_accounting_currency(&self) -> bool { - self.creating_new_profile + self.creating_new_profile && !self.global } // ---- mutations ------------------------------------------------------- @@ -211,10 +212,10 @@ impl TableDraft { /// Every check the client runs before it will save. pub(crate) fn validate(&self) -> Result<(), String> { let profile_name = self.effective_profile_name(); - if self.creating_new_profile && profile_name.is_empty() { + if !self.global && self.creating_new_profile && profile_name.is_empty() { return Err("Enter a name for the new profile.".to_string()); } - if let Some(error) = validate_identifier(&profile_name, "Profile name", false) { + if !self.global && let Some(error) = validate_identifier(&profile_name, "Profile name", false) { return Err(error); } if let Some(error) = validate_accounting_currency(self) { @@ -244,18 +245,19 @@ impl TableDraft { profile_name: self.effective_profile_name(), columns: proto_columns(&self.columns.added), indexes: self.columns.selected_index_names(), - accounting_currency: if self.creating_new_profile { + accounting_currency: if self.creating_new_profile && !self.global { self.accounting_currency.trim().to_ascii_uppercase() } else { String::new() }, row_display_columns: self.row_display_columns.clone(), + global: self.global, }) } } pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { - if !draft.creating_new_profile { + if !draft.creating_new_profile || draft.global { return None; } let currency = draft.accounting_currency.to_ascii_uppercase(); diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 2f3fe748..5d348f50 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -60,7 +60,26 @@ pub(crate) async fn load_page( .into_inner(); let effective_profile = draft.effective_profile_name(); - match tree + if draft.global { + let global_tables = tree + .profiles + .iter() + .flat_map(|profile| profile.tables.iter()) + .filter(|table| table.global) + .map(|table| table.name.clone()) + .collect::>() + .into_iter() + .collect::>(); + draft.existing_profile_tables = tree + .profiles + .iter() + .flat_map(|profile| profile.tables.iter()) + .map(|table| table.name.clone()) + .collect::>() + .into_iter() + .collect(); + draft.set_available_relation_tables(global_tables); + } else { match tree .profiles .iter() .find(|profile| profile.name == effective_profile) @@ -82,7 +101,7 @@ pub(crate) async fn load_page( draft.existing_profile_tables.clear(); draft.relation_tables.clear(); } - } + }} Ok(AddTablePageState { nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 3a08caa3..1ab99ef9 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -98,7 +98,8 @@ pub(crate) async fn create_table( }; let mut definitions = state.definitions; - match definitions.post_table_definition(request).await { + let result = definitions.post_table_definition(request).await; + match result { Ok(response) if response.get_ref().success => { let location = format!( "/admin/table-definition?profile={profile_name}&table={}", diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 7e5d1aa6..1ec2e174 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -34,6 +34,8 @@ pub(crate) struct BuilderForm { #[serde(default)] pub accounting_currency: String, #[serde(default)] + pub global: bool, + #[serde(default)] pub table_name: String, // The pending column being described in the input panel. @@ -134,6 +136,7 @@ impl BuilderForm { profile_name_input: self.profile_name_input.clone(), creating_new_profile, accounting_currency: self.accounting_currency.clone(), + global: self.global, table_name: self.table_name.clone(), columns, relation_tables: self.relation_tables.clone(), diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 235b3ee8..b7c6855a 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -24,6 +24,15 @@

Table

+ + + {% if !page.draft.global %} + {% else %} + + {% endif %} {% if page.draft.show_profile_name_input() %}