diff --git a/client b/client index 05e4d39..8605da5 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 05e4d398d210e60fec1197beb95fcea898caa64a +Subproject commit 8605da5f4869ab05d91b389e69f7f4719ce6e4e1 diff --git a/common/proto/search.proto b/common/proto/search.proto index 344220a..edf8eb7 100644 --- a/common/proto/search.proto +++ b/common/proto/search.proto @@ -44,9 +44,9 @@ message SearchResponse { float score = 2; string content_json = 3; string table_name = 4; - // Configured human-readable value for this row. - string row_display_value = 5; - string row_display_column = 6; + // Configured human-readable values for this row, in display order. + repeated string row_display_values = 5; + repeated string row_display_columns = 6; optional uint64 position = 7; } repeated Hit hits = 1; diff --git a/common/proto/search2.proto b/common/proto/search2.proto index 5becf89..1d5285a 100644 --- a/common/proto/search2.proto +++ b/common/proto/search2.proto @@ -40,8 +40,8 @@ message Search2Response { int64 id = 1; string content_json = 2; // No score - this is SQL-based optional string match_info = 3; // Info about which columns matched - string row_display_value = 4; // Configured human-readable value for this row - string row_display_column = 5; + repeated string row_display_values = 4; // Configured human-readable values for this row + repeated string row_display_columns = 5; } repeated Hit hits = 1; int32 total_count = 2; // Total matching records (for pagination) diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 37995f4..19361bf 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -86,9 +86,10 @@ message PostTableDefinitionRequest { // ISO-4217 base currency used by every MONEY column in this table. string base_currency = 6; - // Column whose value identifies a row to users in pickers. "id" is always - // valid; otherwise this must name one of the user-defined columns above. - string row_display_column = 7; + // Columns whose values identify a row to users in pickers, in the order + // they are shown. Each must name one of the user-defined columns above. + // Empty means the row is identified by its id alone. + repeated string row_display_columns = 7; // ISO-4217 currency the profile keeps its accounting in. A profile is one // accounting entity and keeps one set of books, so this is required only when @@ -117,7 +118,7 @@ message CreateInvoiceTemplateTableRequest { string table_name = 2; string typst_source = 3; string base_currency = 4; - string row_display_column = 5; + repeated string row_display_columns = 5; } // One physical dynamic table created for an invoice template scope. The root @@ -225,8 +226,8 @@ message ProfileTreeResponse { // Other tables this one references (based on link definitions only). repeated string depends_on = 3; - // Column whose value is used as the human-readable row label. - string row_display_column = 4; + // Columns whose values make up the human-readable row label, in order. + repeated string row_display_columns = 4; // "dynamic" for user-defined tables, "system" for backend-managed tables. string table_kind = 5; @@ -304,7 +305,7 @@ message TableDetail { repeated ColumnDefinition columns = 3; repeated ScriptInfo scripts = 4; string base_currency = 5; - string row_display_column = 6; + repeated string row_display_columns = 6; map column_behaviors = 7; string table_kind = 8; } diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index b70fd7b..056b1e6 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -271,10 +271,11 @@ message GetTableDataResponse { // (NULL becomes ""). The row is returned only if deleted = FALSE. map data = 1; - // Configured human-readable value for this row. Empty when that column is - // NULL/empty; clients should then visibly fall back to the row id. - string row_display_value = 2; - string row_display_column = 3; + // Configured human-readable values for this row, in display order. A value + // is empty when its column is NULL/empty; an empty list means the row is + // identified by its id alone. + repeated string row_display_values = 2; + repeated string row_display_columns = 3; } // Count non-deleted rows. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index c57fd5a..8673c3a 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.search.rs b/common/src/proto/komp_ac.search.rs index fccc0dd..1a7b073 100644 --- a/common/src/proto/komp_ac.search.rs +++ b/common/src/proto/komp_ac.search.rs @@ -50,11 +50,13 @@ pub mod search_response { pub content_json: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub table_name: ::prost::alloc::string::String, - /// Configured human-readable value for this row. - #[prost(string, tag = "5")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row, in display order. + #[prost(string, repeated, tag = "5")] + pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "6")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, #[prost(uint64, optional, tag = "7")] pub position: ::core::option::Option, } diff --git a/common/src/proto/komp_ac.search2.rs b/common/src/proto/komp_ac.search2.rs index c006812..e9e455b 100644 --- a/common/src/proto/komp_ac.search2.rs +++ b/common/src/proto/komp_ac.search2.rs @@ -49,11 +49,13 @@ pub mod search2_response { /// Info about which columns matched #[prost(string, optional, tag = "3")] pub match_info: ::core::option::Option<::prost::alloc::string::String>, - /// Configured human-readable value for this row - #[prost(string, tag = "4")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row + #[prost(string, repeated, tag = "4")] + pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "5")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 8ab477e..0dbd787 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -45,10 +45,11 @@ pub struct PostTableDefinitionRequest { /// ISO-4217 base currency used by every MONEY column in this table. #[prost(string, tag = "6")] pub base_currency: ::prost::alloc::string::String, - /// Column whose value identifies a row to users in pickers. "id" is always - /// valid; otherwise this must name one of the user-defined columns above. - #[prost(string, tag = "7")] - pub row_display_column: ::prost::alloc::string::String, + /// Columns whose values identify a row to users in pickers, in the order + /// they are shown. Each must name one of the user-defined columns above. + /// Empty means the row is identified by its id alone. + #[prost(string, repeated, tag = "7")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, /// ISO-4217 currency the profile keeps its accounting in. A profile is one /// accounting entity and keeps one set of books, so this is required only when /// the request creates the profile, and is ignored afterwards. It is unrelated @@ -82,8 +83,8 @@ pub struct CreateInvoiceTemplateTableRequest { pub typst_source: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub base_currency: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub row_display_column: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "5")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } /// One physical dynamic table created for an invoice template scope. The root /// has an empty collection_path and parent_table_name. Each \[\] scope names its @@ -205,9 +206,11 @@ pub mod profile_tree_response { /// Other tables this one references (based on link definitions only). #[prost(string, repeated, tag = "3")] pub depends_on: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Column whose value is used as the human-readable row label. - #[prost(string, tag = "4")] - pub row_display_column: ::prost::alloc::string::String, + /// Columns whose values make up the human-readable row label, in order. + #[prost(string, repeated, tag = "4")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, /// "dynamic" for user-defined tables, "system" for backend-managed tables. #[prost(string, tag = "5")] pub table_kind: ::prost::alloc::string::String, @@ -313,8 +316,8 @@ pub struct TableDetail { pub scripts: ::prost::alloc::vec::Vec, #[prost(string, tag = "5")] pub base_currency: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub row_display_column: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "6")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(map = "string, message", tag = "7")] pub column_behaviors: ::std::collections::HashMap< ::prost::alloc::string::String, diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index 0bf47fa..fa82b8d 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -216,12 +216,13 @@ pub struct GetTableDataResponse { ::prost::alloc::string::String, ::prost::alloc::string::String, >, - /// Configured human-readable value for this row. Empty when that column is - /// NULL/empty; clients should then visibly fall back to the row id. - #[prost(string, tag = "2")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row, in display order. A value + /// is empty when its column is NULL/empty; an empty list means the row is + /// identified by its id alone. + #[prost(string, repeated, tag = "2")] + 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>, } /// Count non-deleted rows. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/graphs/src/pages/add_table/state.rs b/graphs/src/pages/add_table/state.rs index 18fad18..3fda684 100644 --- a/graphs/src/pages/add_table/state.rs +++ b/graphs/src/pages/add_table/state.rs @@ -18,8 +18,8 @@ pub(crate) struct CreateTableForm { pub optional_links: String, #[serde(default)] pub base_currency: String, - #[serde(default = "default_row_display_column")] - pub row_display_column: String, + #[serde(default)] + pub row_display_columns: String, } pub(crate) struct AddTablePageState { @@ -121,10 +121,7 @@ impl CreateTableForm { indexes, profile_name, base_currency: if has_money { base_currency } else { String::new() }, - row_display_column: { - let value = self.row_display_column.trim(); - if value.is_empty() { "id".to_string() } else { value.to_string() } - }, + row_display_columns: comma_separated(&self.row_display_columns), }) } } @@ -138,10 +135,6 @@ fn comma_separated(value: &str) -> Vec { .collect() } -fn default_row_display_column() -> String { - "id".to_string() -} - #[cfg(test)] mod tests { use super::*; @@ -154,7 +147,7 @@ mod tests { columns: "number: text:indexed\namount: money:half-up,recompute".into(), required_links: "customer".into(), base_currency: "eur".into(), - row_display_column: "number".into(), + row_display_columns: "number, amount".into(), ..Default::default() } .into_request() @@ -164,7 +157,7 @@ mod tests { assert_eq!(request.links[0].linked_table_name, "customer"); assert!(request.links[0].required); assert_eq!(request.base_currency, "EUR"); - assert_eq!(request.row_display_column, "number"); + assert_eq!(request.row_display_columns, vec!["number", "amount"]); assert!(request.columns[1].recompute_on_dependency_change); } } diff --git a/graphs/src/pages/add_table/ui.rs b/graphs/src/pages/add_table/ui.rs index 36b6704..0f099e0 100644 --- a/graphs/src/pages/add_table/ui.rs +++ b/graphs/src/pages/add_table/ui.rs @@ -21,14 +21,14 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String { .map(render_submission_error) .unwrap_or_default(); format!( - "Add table
Komp Accounting
← Admin panel

Table definition

Add table

Create a table through the existing gRPC table-definition service.

{error}
Cancel
", + "Add table
Komp Accounting
← Admin panel

Table definition

Add table

Create a table through the existing gRPC table-definition service.

{error}
Cancel
", crate::escape_html(&page.form.table_name), crate::escape_html(&page.form.columns), crate::escape_html(&page.form.indexed_columns), crate::escape_html(&page.form.base_currency), crate::escape_html(&page.form.required_links), crate::escape_html(&page.form.optional_links), - crate::escape_html(&page.form.row_display_column), + crate::escape_html(&page.form.row_display_columns), ) } diff --git a/graphs/src/pages/admin/admin/loader.rs b/graphs/src/pages/admin/admin/loader.rs index 72c9eb5..31875b0 100644 --- a/graphs/src/pages/admin/admin/loader.rs +++ b/graphs/src/pages/admin/admin/loader.rs @@ -69,7 +69,7 @@ pub(crate) async fn load_admin_page( .map(|table| TableView { name: table.name.clone(), depends_on: table.depends_on.clone(), - row_display_column: table.row_display_column.clone(), + row_display_columns: table.row_display_columns.clone(), }) .collect::>() }) diff --git a/graphs/src/pages/admin/admin/state.rs b/graphs/src/pages/admin/admin/state.rs index 87a3a61..18e1c53 100644 --- a/graphs/src/pages/admin/admin/state.rs +++ b/graphs/src/pages/admin/admin/state.rs @@ -26,7 +26,7 @@ pub(crate) struct ProfileView { pub(crate) struct TableView { pub name: String, pub depends_on: Vec, - pub row_display_column: String, + pub row_display_columns: Vec, } #[derive(Debug)] diff --git a/graphs/src/pages/admin/admin/ui.rs b/graphs/src/pages/admin/admin/ui.rs index 33081e1..f89d854 100644 --- a/graphs/src/pages/admin/admin/ui.rs +++ b/graphs/src/pages/admin/admin/ui.rs @@ -69,7 +69,7 @@ fn render_tables(page: &AdminPageState) -> String { crate::escape_html(&table.name), crate::escape_html(&table.name), crate::escape_html(&dependencies), - crate::escape_html(&table.row_display_column), + crate::escape_html(&table.row_display_columns.join(", ")), ) }) .collect() diff --git a/search/src/lib.rs b/search/src/lib.rs index fde5b71..294f894 100644 --- a/search/src/lib.rs +++ b/search/src/lib.rs @@ -504,14 +504,14 @@ async fn table_physical_to_display_map( Ok(mapping) } -async fn table_row_display_column( +async fn table_row_display_columns( pool: &PgPool, profile_name: &str, table_name: &str, -) -> Result { +) -> Result, Status> { sqlx::query_scalar( r#" - SELECT td.row_display_column + SELECT td.row_display_columns FROM schemas s JOIN table_definitions td ON td.schema_id = s.id WHERE s.name = $1 AND td.table_name = $2 @@ -521,7 +521,7 @@ async fn table_row_display_column( .bind(table_name) .fetch_one(pool) .await - .map_err(|e| Status::internal(format!("Row display column lookup failed: {}", e))) + .map_err(|e| Status::internal(format!("Row display columns lookup failed: {}", e))) } fn remap_json_to_display_names( @@ -541,13 +541,18 @@ fn remap_json_to_display_names( } } -fn row_display_value(value: &serde_json::Value, column: &str) -> String { - match value.get(column) { - Some(serde_json::Value::String(value)) => value.clone(), - Some(serde_json::Value::Number(value)) => value.to_string(), - Some(serde_json::Value::Bool(value)) => value.to_string(), - _ => String::new(), - } +/// One value per display column, positionally aligned with them, so a column +/// that is NULL for this row stays visible as an empty slot. +fn row_display_values(value: &serde_json::Value, columns: &[String]) -> Vec { + columns + .iter() + .map(|column| match value.get(column) { + Some(serde_json::Value::String(value)) => value.clone(), + Some(serde_json::Value::Number(value)) => value.to_string(), + Some(serde_json::Value::Bool(value)) => value.to_string(), + _ => String::new(), + }) + .collect() } enum ResolvedOrderColumn { @@ -573,8 +578,14 @@ async fn resolve_order_column( return Ok(ResolvedOrderColumn::Position); } - let requested_column = if requested_column.eq_ignore_ascii_case("row_display_column") { - table_row_display_column(pool, profile_name, table_name).await? + // Sorting by "the display column" means the first one: it is the part + // callers read left to right. A table with none sorts by id instead. + let requested_column = if requested_column.eq_ignore_ascii_case("row_display_columns") { + table_row_display_columns(pool, profile_name, table_name) + .await? + .into_iter() + .next() + .unwrap_or_else(|| "id".to_string()) } else { requested_column.to_string() }; @@ -661,7 +672,7 @@ async fn fetch_ordered_rows( offset: usize, ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; - let display_column = table_row_display_column(pool, profile_name, table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; let (resolved_order, direction) = match order { Some(order) => ( resolve_order_column(pool, profile_name, table_name, &order.column).await?, @@ -694,14 +705,14 @@ async fn fetch_ordered_rows( let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); let position: i64 = row.try_get("picker_position").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let row_display_value = row_display_value(&json_data, &display_column); + let row_display_values = row_display_values(&json_data, &display_columns); Hit { id, score: 0.0, content_json: json_data.to_string(), table_name: table_name.to_string(), - row_display_value, - row_display_column: display_column.clone(), + row_display_values, + row_display_columns: display_columns.clone(), position: u64::try_from(position).ok(), } }) @@ -810,12 +821,13 @@ async fn run_search( .push(*pg_id); } - let mut content_map: HashMap<(String, i64), (String, String, String)> = HashMap::new(); + let mut content_map: HashMap<(String, i64), (String, Vec, Vec)> = + HashMap::new(); for (table_name, pg_ids) in ids_by_table { validate_identifier(&table_name, "table_name")?; let physical_to_display = table_physical_to_display_map(pool, profile_name, &table_name).await?; - let display_column = table_row_display_column(pool, profile_name, &table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?; let sql = format!( "SELECT id, to_jsonb(t) AS data FROM {} t WHERE deleted = FALSE AND id = ANY($1)", qualify_profile_table(profile_name, &table_name) @@ -830,10 +842,10 @@ async fn run_search( let id: i64 = row.try_get("id").unwrap_or_default(); let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let display_value = row_display_value(&json_data, &display_column); + let display_values = row_display_values(&json_data, &display_columns); content_map.insert( (table_name.clone(), id), - (json_data.to_string(), display_value, display_column.clone()), + (json_data.to_string(), display_values, display_columns.clone()), ); } } @@ -843,13 +855,13 @@ async fn run_search( .filter_map(|(score, pg_id, table_name)| { content_map .get(&(table_name.clone(), pg_id)) - .map(|(content_json, row_display_value, row_display_column)| Hit { + .map(|(content_json, row_display_values, row_display_columns)| Hit { id: pg_id, score, content_json: content_json.clone(), table_name, - row_display_value: row_display_value.clone(), - row_display_column: row_display_column.clone(), + row_display_values: row_display_values.clone(), + row_display_columns: row_display_columns.clone(), position: None, }) }) @@ -866,7 +878,7 @@ async fn fetch_ordered_candidate_rows( offset: usize, ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; - let display_column = table_row_display_column(pool, profile_name, table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; let resolved_order = resolve_order_column(pool, profile_name, table_name, &order.column).await?; let sql = format!( @@ -919,14 +931,14 @@ async fn fetch_ordered_candidate_rows( let position: i64 = row.try_get("picker_position").unwrap_or_default(); let score: f32 = row.try_get("candidate_score").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let display_value = row_display_value(&json_data, &display_column); + let display_values = row_display_values(&json_data, &display_columns); Hit { id, score, content_json: json_data.to_string(), table_name: table_name.to_string(), - row_display_value: display_value, - row_display_column: display_column.clone(), + row_display_values: display_values, + row_display_columns: display_columns.clone(), position: u64::try_from(position).ok(), } }) diff --git a/server b/server index a8bc888..01ce5a6 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit a8bc888a22ce38fc7871bbf882605bfd4edaf43d +Subproject commit 01ce5a63b7ffd8ccc6336cb508364e69c4d6b40b