From c3f5046c85f644eac5382b7710f2bdf5d5a0f240 Mon Sep 17 00:00:00 2001 From: Priec Date: Fri, 14 Aug 2026 21:23:14 +0200 Subject: [PATCH] aliasing now move column position also --- client | 2 +- client-server-drift.md | 4 +- common/build.rs | 8 +- common/proto/table_definition.proto | 35 +++++--- common/proto/table_structure.proto | 3 + common/src/proto/descriptor.bin | Bin 182366 -> 183231 bytes common/src/proto/komp_ac.table_definition.rs | 76 +++++++++++------- common/src/proto/komp_ac.table_structure.rs | 3 + server | 2 +- web/CHANGELOG.md | 4 +- web/src/lib.rs | 2 +- .../pages/admin/table_definition/loader.rs | 2 +- web/src/pages/admin/table_definition/logic.rs | 48 ++++++++--- web/src/pages/admin/table_definition/mod.rs | 2 +- web/src/pages/admin/table_definition/state.rs | 16 ++-- web/src/pages/admin/table_definition/ui.rs | 18 ++++- .../admin/table_definition/columns_panel.html | 25 +++--- 17 files changed, 164 insertions(+), 86 deletions(-) diff --git a/client b/client index be0c5526..429600a1 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit be0c55263882d27a3f428268d695fcbf328161cf +Subproject commit 429600a18198dfb7f4f6ece4fc92813252e8389b diff --git a/client-server-drift.md b/client-server-drift.md index 22f13889..0ce2d7b9 100644 --- a/client-server-drift.md +++ b/client-server-drift.md @@ -45,8 +45,8 @@ Commit 29ccd8d added `ColumnDefinition.required` end-to-end (definition → vali **10. `account` field is bare.** The server exposes physical `account_id` as API column `account` — TEXT, slash-delimited, required on ACCOUNTING tables, and sending `account_id` directly is rejected (`server/src/tables_data/account_binding.rs`, `table_structure/query.rs:258`). The client removed its old `account_id → accounts` special case (client 827e6a4) and added no `account` handling: the form shows it as a plain TEXT field with no account picker or format validation. The ledger display part was fixed (`ledger.rs` renders `line.account`). -**11. No alias rename UI.** -`rename_column_alias` is defined in the client's gRPC client (`grpc_client.rs:441`) but never called; the server's renameable-alias feature (8881041) is unreachable from the client. +**11. Column presentation is managed by the table-definition UI.** +`SetColumnPresentation` atomically updates aliases and presentation order from the admin table workspace. **12. `CreateInvoiceTemplateTable` not used.** The server added table bundles generated from Typst invoice templates (a5c8e08); the client ships the `typst-template` feature but has no call or screen for it. diff --git a/common/build.rs b/common/build.rs index 618d93f5..29a3faaf 100644 --- a/common/build.rs +++ b/common/build.rs @@ -208,11 +208,15 @@ fn main() -> Result<(), Box> { "#[derive(serde::Serialize, serde::Deserialize)]" ) .type_attribute( - ".komp_ac.table_definition.RenameColumnAliasRequest", + ".komp_ac.table_definition.ColumnPresentation", "#[derive(serde::Serialize, serde::Deserialize)]" ) .type_attribute( - ".komp_ac.table_definition.RenameColumnAliasResponse", + ".komp_ac.table_definition.SetColumnPresentationRequest", + "#[derive(serde::Serialize, serde::Deserialize)]" + ) + .type_attribute( + ".komp_ac.table_definition.SetColumnPresentationResponse", "#[derive(serde::Serialize, serde::Deserialize)]" ) .type_attribute( diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 9357a7be..25b767cf 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -45,8 +45,9 @@ service TableDefinition { // Returns the stored rename history for column aliases in one profile. rpc GetColumnAliasRenameHistory(GetColumnAliasRenameHistoryRequest) returns (GetColumnAliasRenameHistoryResponse); - // Renames a user-visible column alias while keeping the physical column unchanged. - rpc RenameColumnAlias(RenameColumnAliasRequest) returns (RenameColumnAliasResponse); + // Atomically replaces the aliases and presentation order of a table's columns. + // Physical column names and identities remain unchanged. + rpc SetColumnPresentation(SetColumnPresentationRequest) returns (SetColumnPresentationResponse); // Drops a table and its metadata, then deletes the profile if it becomes empty. rpc DeleteTable(DeleteTableRequest) returns (DeleteTableResponse); @@ -106,12 +107,12 @@ message PostTableDefinitionRequest { // is required to be named after its own type, and what it expands into is the // backend's to decide. The name is only ever a display name over a physical // column, though, so it is free to be anything: this is where that choice is -// made, instead of a RenameColumnAlias call afterwards. +// made, instead of a SetColumnPresentation call afterwards. // // ACCOUNTING, PHONE and IBAN generated columns may be renamed: their // relationships are recorded by physical column, so the rest of the system can // find them without knowing their display names. ACCOUNTING_TRANSFER connectors -// are the exception and are refused here exactly as RenameColumnAlias refuses +// are the exception and are refused here exactly as SetColumnPresentation refuses // them. message GeneratedColumnAlias { // The name the backend would otherwise give the column: one of ACCOUNTING's @@ -389,6 +390,9 @@ message ColumnBehavior { // True when clients may offer this column in an alias rename picker. bool renameable = 4; + + // Stable public identity used when updating this column's presentation. + int64 column_id = 5; } // A script that targets a specific column in a table. @@ -400,18 +404,25 @@ message ScriptInfo { string description = 5; } -// Request to rename one user-visible column alias in a table. -message RenameColumnAliasRequest { - string profile_name = 1; - string table_name = 2; - string old_column_name = 3; - string new_column_name = 4; +// One column's desired public presentation. Its position in the containing +// repeated field is its presentation order. +message ColumnPresentation { + // Stable public identity of the table_definition_columns row. + int64 column_id = 1; + string alias = 2; } -// Response after renaming one column alias. -message RenameColumnAliasResponse { +// Atomically replaces every user-visible column alias and their order. +message SetColumnPresentationRequest { + string profile_name = 1; + string table_name = 2; + repeated ColumnPresentation columns = 3; +} + +message SetColumnPresentationResponse { bool success = 1; string message = 2; + repeated ColumnPresentation columns = 3; } // Request to delete one table definition entirely. diff --git a/common/proto/table_structure.proto b/common/proto/table_structure.proto index d2da235f..15861a2c 100644 --- a/common/proto/table_structure.proto +++ b/common/proto/table_structure.proto @@ -87,4 +87,7 @@ message TableColumn { // True when clients may offer this column in an alias rename picker. bool renameable = 9; + + // Stable public identity of a managed user column. Zero for system columns. + int64 column_id = 10; } diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 9269e9c2d2916cfd48bbee3a5690169fbfb20bd2..8b5f22767c28ab5fcbf53ef81e76e20dfc6bd9b5 100644 GIT binary patch delta 9100 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 delta 8216 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, } -/// Response after renaming one column alias. #[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RenameColumnAliasResponse { +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetColumnPresentationResponse { #[prost(bool, tag = "1")] pub success: bool, #[prost(string, tag = "2")] pub message: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "3")] + pub columns: ::prost::alloc::vec::Vec, } /// Request to delete one table definition entirely. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -987,12 +1000,13 @@ pub mod table_definition_client { ); self.inner.unary(req, path, codec).await } - /// Renames a user-visible column alias while keeping the physical column unchanged. - pub async fn rename_column_alias( + /// Atomically replaces the aliases and presentation order of a table's columns. + /// Physical column names and identities remain unchanged. + pub async fn set_column_presentation( &mut self, - request: impl tonic::IntoRequest, + request: impl tonic::IntoRequest, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, > { self.inner @@ -1005,14 +1019,14 @@ pub mod table_definition_client { })?; let codec = tonic_prost::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/komp_ac.table_definition.TableDefinition/RenameColumnAlias", + "/komp_ac.table_definition.TableDefinition/SetColumnPresentation", ); let mut req = request.into_request(); req.extensions_mut() .insert( GrpcMethod::new( "komp_ac.table_definition.TableDefinition", - "RenameColumnAlias", + "SetColumnPresentation", ), ); self.inner.unary(req, path, codec).await @@ -1143,12 +1157,13 @@ pub mod table_definition_server { tonic::Response, tonic::Status, >; - /// Renames a user-visible column alias while keeping the physical column unchanged. - async fn rename_column_alias( + /// Atomically replaces the aliases and presentation order of a table's columns. + /// Physical column names and identities remain unchanged. + async fn set_column_presentation( &self, - request: tonic::Request, + request: tonic::Request, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, >; /// Drops a table and its metadata, then deletes the profile if it becomes empty. @@ -1670,25 +1685,28 @@ pub mod table_definition_server { }; Box::pin(fut) } - "/komp_ac.table_definition.TableDefinition/RenameColumnAlias" => { + "/komp_ac.table_definition.TableDefinition/SetColumnPresentation" => { #[allow(non_camel_case_types)] - struct RenameColumnAliasSvc(pub Arc); + struct SetColumnPresentationSvc(pub Arc); impl< T: TableDefinition, - > tonic::server::UnaryService - for RenameColumnAliasSvc { - type Response = super::RenameColumnAliasResponse; + > tonic::server::UnaryService + for SetColumnPresentationSvc { + type Response = super::SetColumnPresentationResponse; type Future = BoxFuture< tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::rename_column_alias(&inner, request) + ::set_column_presentation( + &inner, + request, + ) .await }; Box::pin(fut) @@ -1700,7 +1718,7 @@ pub mod table_definition_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = RenameColumnAliasSvc(inner); + let method = SetColumnPresentationSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( diff --git a/common/src/proto/komp_ac.table_structure.rs b/common/src/proto/komp_ac.table_structure.rs index 8bd1e790..1da7b7a9 100644 --- a/common/src/proto/komp_ac.table_structure.rs +++ b/common/src/proto/komp_ac.table_structure.rs @@ -70,6 +70,9 @@ pub struct TableColumn { /// True when clients may offer this column in an alias rename picker. #[prost(bool, tag = "9")] pub renameable: bool, + /// Stable public identity of a managed user column. Zero for system columns. + #[prost(int64, tag = "10")] + pub column_id: i64, } /// Generated client implementations. pub mod table_structure_service_client { diff --git a/server b/server index 0d96c9e3..481719e3 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 0d96c9e38045242483ffb09b7fd10e5ecb221568 +Subproject commit 481719e3cb0d6960528f47c93f03975bb4a15ad3 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index b0c6cdcc..9d0068a2 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -160,8 +160,8 @@ response is consumed. (table, old/new column names, timestamp); only those four fields are rendered. - **`TableDefinition.AddTableColumns`** — called by `POST /admin/table-definition/columns`. Adds columns to an existing table. -- **`TableDefinition.RenameColumnAlias`** — called by - `POST /admin/table-definition/rename`. Renames a table column alias. +- **`TableDefinition.SetColumnPresentation`** — called by + `POST /admin/tables/presentation`. Atomically changes column aliases and order. - **`TableDefinition.DeleteTable`** — called by `POST /admin/table-definition/delete`. Deletes a table definition. - **`TableDefinition.CopyProfile`** — called by diff --git a/web/src/lib.rs b/web/src/lib.rs index b4601811..9fc5e1d4 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -323,7 +323,7 @@ mod tests { for path in [ "/admin/tables/columns", "/admin/tables/columns/builder", - "/admin/tables/rename", + "/admin/tables/presentation", "/admin/tables/delete", "/admin/profiles/copy", "/admin/tables/from-template", diff --git a/web/src/pages/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index e88d8cb9..769793b6 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -200,6 +200,7 @@ pub(crate) async fn load_page( .map(|column| { let behavior = table.column_behaviors.get(&column.name); DetailColumn { + column_id: behavior.map(|behavior| behavior.column_id).unwrap_or_default(), name: column.name.clone(), sql_type: catalog.sql_type(&column.field_type), field_type: column.field_type.clone(), @@ -279,7 +280,6 @@ pub(crate) async fn load_page( history, selection: inputs.selection, columns: inputs.columns, - rename: inputs.rename, copy: inputs.copy, invoice: inputs.invoice, status: inputs.status, diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 61bbefe2..d1d6174a 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -23,7 +23,7 @@ use crate::{ AppState, definitions::table_definition::{ AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest, - DeleteTableRequest, RenameColumnAliasRequest, + ColumnPresentation, DeleteTableRequest, SetColumnPresentationRequest, }, {i18n::Locale, tr}, schema::{ColumnForm, proto_columns}, @@ -34,7 +34,7 @@ use super::{ loader::{self, load_page}, state::{ CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs, - RenameForm, Selection, TableDefinitionPageState, + PresentationForm, Selection, TableDefinitionPageState, }, ui, }; @@ -303,11 +303,11 @@ pub(crate) async fn add_columns( } } -/// POST /admin/tables/rename — RenameColumnAlias. -pub(crate) async fn rename_column( +/// POST /admin/tables/presentation — SetColumnPresentation. +pub(crate) async fn set_column_presentation( State(state): State, headers: HeaderMap, - Form(form): Form, + Form(form): Form, ) -> Response { if let Some(rejection) = reject_cross_site(&headers) { return rejection; @@ -317,9 +317,12 @@ pub(crate) async fn rename_column( profile: form.profile.clone(), table: form.table.clone(), }); - inputs.rename = form.clone(); + inputs.presentation = form.clone(); - if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() { + if form.column_ids.is_empty() + || form.column_ids.len() != form.aliases.len() + || form.aliases.iter().any(|alias| alias.trim().is_empty()) + { let message = tr!( Locale::from_headers(&headers), "td-err-choose-rename" @@ -334,21 +337,42 @@ pub(crate) async fn rename_column( .await; } - let request = RenameColumnAliasRequest { + let mut columns = form + .column_ids + .iter() + .copied() + .zip(form.aliases.iter()) + .map(|(column_id, alias)| ColumnPresentation { + column_id, + alias: alias.trim().to_string(), + }) + .collect::>(); + if let Some((direction, index)) = form.action.split_once(':') { + if let Ok(index) = index.parse::() { + let other = match direction { + "up" => index.checked_sub(1), + "down" if index + 1 < columns.len() => Some(index + 1), + _ => None, + }; + if let Some(other) = other { + columns.swap(index, other); + } + } + } + let request = SetColumnPresentationRequest { profile_name: form.profile.clone(), table_name: form.table.clone(), - old_column_name: form.old_column_name.clone(), - new_column_name: form.new_column_name.trim().to_string(), + columns, }; let Ok(request) = authenticated_request(&headers, request) else { return Redirect::to("/login").into_response(); }; let mut definitions = state.definitions.clone(); - match definitions.rename_column_alias(request).await { + match definitions.set_column_presentation(request).await { Ok(response) if response.get_ref().success => { inputs.status = Some(response.into_inner().message); - inputs.rename = RenameForm { + inputs.presentation = PresentationForm { profile: form.profile, table: form.table, ..Default::default() diff --git a/web/src/pages/admin/table_definition/mod.rs b/web/src/pages/admin/table_definition/mod.rs index e6f5e82c..0af8ec53 100644 --- a/web/src/pages/admin/table_definition/mod.rs +++ b/web/src/pages/admin/table_definition/mod.rs @@ -36,7 +36,7 @@ pub(crate) fn router() -> Router { "/admin/tables/columns/builder", post(logic::update_columns), ) - .route("/admin/tables/rename", post(logic::rename_column)) + .route("/admin/tables/presentation", post(logic::set_column_presentation)) .route("/admin/tables/delete", get(logic::delete_page)) .route("/admin/tables/delete", post(logic::delete_table)) // Profile-scoped. diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 96920668..3a13e7d5 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -89,6 +89,7 @@ impl TableDetailView { /// Columns a rename may target. Provenance and renameability are separate: /// accounting companions remain renameable while protected generated /// columns do not. + #[cfg(test)] pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> { self.columns .iter() @@ -99,6 +100,7 @@ impl TableDetailView { #[derive(Clone, Debug)] pub(crate) struct DetailColumn { + pub column_id: i64, pub name: String, pub field_type: String, /// The PostgreSQL type the column is stored as, from the column-type @@ -173,15 +175,17 @@ pub(crate) struct GeneratedTableView { /// The rename panel's inputs, kept across a failed submit so the user does not /// retype them. #[derive(Clone, Debug, Default, serde::Deserialize)] -pub(crate) struct RenameForm { +pub(crate) struct PresentationForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] - pub old_column_name: String, + pub column_ids: Vec, #[serde(default)] - pub new_column_name: String, + pub aliases: Vec, + #[serde(default)] + pub action: String, } /// The copy-profile panel. An empty `table_names` copies the whole profile, @@ -241,7 +245,7 @@ pub(crate) struct PageInputs { pub selection: Selection, /// The columns staged for `AddTableColumns`. pub columns: ColumnDraft, - pub rename: RenameForm, + pub presentation: PresentationForm, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, @@ -278,7 +282,6 @@ pub(crate) struct TableDefinitionPageState { pub detail: Option, pub history: Vec, pub columns: ColumnDraft, - pub rename: RenameForm, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, @@ -388,6 +391,7 @@ mod tests { scripts: Vec::new(), columns: vec![ DetailColumn { + column_id: 1, name: "work_phone".to_string(), field_type: "phone".to_string(), sql_type: "TEXT".to_string(), @@ -400,6 +404,7 @@ mod tests { renameable: true, }, DetailColumn { + column_id: 2, name: "work_phone_country".to_string(), field_type: "phone_country".to_string(), sql_type: "TEXT".to_string(), @@ -412,6 +417,7 @@ mod tests { renameable: false, }, DetailColumn { + column_id: 3, name: "charge".to_string(), field_type: "money".to_string(), sql_type: "NUMERIC".to_string(), diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 97f4cbe4..b2543216 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -211,7 +211,7 @@ mod tests { use super::*; use crate::{ pages::admin::table_definition::state::{ - CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView, + CopyForm, DetailColumn, InvoiceTemplateForm, Selection, TableDetailView, TableSummary, }, schema::ColumnDraft, @@ -240,6 +240,7 @@ mod tests { row_display_columns: vec!["number".to_string()], scripts: Vec::new(), columns: vec![DetailColumn { + column_id: 1, name: "number".to_string(), field_type: "text".to_string(), sql_type: "TEXT".to_string(), @@ -254,7 +255,6 @@ mod tests { }), history: Vec::new(), columns: ColumnDraft::for_append(crate::schema::tests::catalog()), - rename: RenameForm::default(), copy: CopyForm::default(), invoice: InvoiceTemplateForm::default(), status: None, @@ -307,7 +307,7 @@ mod tests { assert!(html.contains("/admin/tables/delete")); assert!(html.contains("Type invoice to confirm")); // The other writes are links in the switcher, not forms on the page. - assert!(!html.contains("/admin/tables/rename")); + assert!(!html.contains("/admin/tables/presentation")); assert!(!html.contains("/admin/profiles/copy?profile=billing\" method")); } @@ -368,7 +368,17 @@ mod tests { assert!(!html.contains(r#"name="confirm_table_name""#)); let html = render_columns_page(&state); - assert!(!html.contains("/admin/tables/rename")); + assert!(!html.contains("/admin/tables/presentation")); + } + + #[test] + fn column_presentation_posts_stable_ids_aliases_and_order_controls() { + let html = render_columns_page(&page()); + + assert!(html.contains(r#"hx-post="/admin/tables/presentation""#), "{html}"); + assert!(html.contains(r#"name="column_ids" value="1""#), "{html}"); + assert!(html.contains(r#"name="aliases" value="number""#), "{html}"); + assert!(html.contains(r#"name="action" value="save""#), "{html}"); } /// The profile-wide pages need only a profile, and say so by still diff --git a/web/templates/pages/admin/table_definition/columns_panel.html b/web/templates/pages/admin/table_definition/columns_panel.html index b00969d9..8c751955 100644 --- a/web/templates/pages/admin/table_definition/columns_panel.html +++ b/web/templates/pages/admin/table_definition/columns_panel.html @@ -28,25 +28,24 @@

{{ nav.tr("td-rename-column") }}

{{ nav.tr("td-rename-hint") }}

-
- - + {% for column in detail.columns %} + + +
+ + +
+ {% endfor %}
- +