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 9269e9c2..8b5f2276 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.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 5f7a9cce..c264b1bd 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -54,12 +54,12 @@ pub struct 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. #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -401,6 +401,9 @@ pub struct ColumnBehavior { /// True when clients may offer this column in an alias rename picker. #[prost(bool, tag = "4")] pub renameable: bool, + /// Stable public identity used when updating this column's presentation. + #[prost(int64, tag = "5")] + pub column_id: i64, } /// A script that targets a specific column in a table. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -416,27 +419,37 @@ pub struct ScriptInfo { #[prost(string, tag = "5")] pub description: ::prost::alloc::string::String, } -/// Request to rename one user-visible column alias in a table. +/// One column's desired public presentation. Its position in the containing +/// repeated field is its presentation order. #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RenameColumnAliasRequest { +pub struct ColumnPresentation { + /// Stable public identity of the table_definition_columns row. + #[prost(int64, tag = "1")] + pub column_id: i64, + #[prost(string, tag = "2")] + pub alias: ::prost::alloc::string::String, +} +/// Atomically replaces every user-visible column alias and their order. +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetColumnPresentationRequest { #[prost(string, tag = "1")] pub profile_name: ::prost::alloc::string::String, #[prost(string, tag = "2")] pub table_name: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub old_column_name: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub new_column_name: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "3")] + pub columns: ::prost::alloc::vec::Vec, } -/// 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 %}
- +