aboutsummaryrefslogtreecommitdiff
path: root/src/ui/issues.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/ui/issues.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/ui/issues.rs')
-rw-r--r--src/ui/issues.rs773
1 files changed, 773 insertions, 0 deletions
diff --git a/src/ui/issues.rs b/src/ui/issues.rs
new file mode 100644
index 0000000..163a500
--- /dev/null
+++ b/src/ui/issues.rs
@@ -0,0 +1,773 @@
+use eframe::egui;
+
+use crate::api::ApiClient;
+use crate::core::components::interactions::ConfirmDialog;
+use crate::core::tables::get_issues;
+use crate::core::{EditorField, FieldType, FormBuilder};
+use std::collections::HashSet;
+
+#[derive(Clone)]
+struct ColumnConfig {
+ name: String,
+ field: String,
+ visible: bool,
+}
+
+pub struct IssuesView {
+ rows: Vec<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ init_loaded: bool,
+ // Cached summary stats (avoid recomputing every frame)
+ summary_by_status: Vec<(String, i32)>,
+ summary_by_severity: Vec<(String, i32)>,
+ // Columns & selector
+ columns: Vec<ColumnConfig>,
+ show_column_panel: bool,
+ // Selection & interactions
+ selected_row: Option<usize>,
+ last_click_time: Option<std::time::Instant>,
+ last_click_row: Option<usize>,
+ selected_rows: HashSet<usize>,
+ selection_anchor: Option<usize>,
+ // Dialogs
+ delete_dialog: ConfirmDialog,
+ edit_dialog: FormBuilder,
+ // Track ids for operations
+ edit_current_id: Option<i64>,
+ delete_current_id: Option<i64>,
+}
+
+impl IssuesView {
+ pub fn new() -> Self {
+ let columns = vec![
+ ColumnConfig {
+ name: "Title".into(),
+ field: "title".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Status".into(),
+ field: "status".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Severity".into(),
+ field: "severity".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Priority".into(),
+ field: "priority".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Asset".into(),
+ field: "asset_label".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Borrower".into(),
+ field: "borrower_name".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Assigned To".into(),
+ field: "assigned_to_name".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Auto".into(),
+ field: "auto_detected".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Trigger".into(),
+ field: "detection_trigger".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Updated".into(),
+ field: "updated_at".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Created".into(),
+ field: "created_at".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Resolved".into(),
+ field: "resolved_date".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Description".into(),
+ field: "description".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Solution".into(),
+ field: "solution".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Solution+".into(),
+ field: "solution_plus".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Replacement".into(),
+ field: "replacement_asset_id".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Cost".into(),
+ field: "cost".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Notes".into(),
+ field: "notes".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "ID".into(),
+ field: "id".into(),
+ visible: false,
+ },
+ ];
+ Self {
+ rows: vec![],
+ is_loading: false,
+ last_error: None,
+ init_loaded: false,
+ summary_by_status: Vec::new(),
+ summary_by_severity: Vec::new(),
+ columns,
+ show_column_panel: false,
+ selected_row: None,
+ last_click_time: None,
+ last_click_row: None,
+ selected_rows: HashSet::new(),
+ selection_anchor: None,
+ delete_dialog: ConfirmDialog::new(
+ "Delete Issue",
+ "Are you sure you want to delete this issue?",
+ ),
+ edit_dialog: FormBuilder::new(
+ "Edit Issue",
+ vec![
+ EditorField {
+ name: "title".into(),
+ label: "Title".into(),
+ field_type: FieldType::Text,
+ required: true,
+ read_only: false,
+ },
+ EditorField {
+ name: "status".into(),
+ label: "Status".into(),
+ field_type: FieldType::Text,
+ required: true,
+ read_only: false,
+ },
+ EditorField {
+ name: "severity".into(),
+ label: "Severity".into(),
+ field_type: FieldType::Text,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "priority".into(),
+ label: "Priority".into(),
+ field_type: FieldType::Text,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "description".into(),
+ label: "Description".into(),
+ field_type: FieldType::MultilineText,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "solution".into(),
+ label: "Solution".into(),
+ field_type: FieldType::MultilineText,
+ required: false,
+ read_only: false,
+ },
+ ],
+ ),
+ edit_current_id: None,
+ delete_current_id: None,
+ }
+ }
+
+ fn load(&mut self, api: &ApiClient) {
+ if self.is_loading {
+ return;
+ }
+ self.is_loading = true;
+ self.last_error = None;
+ match get_issues(api, Some(200)) {
+ Ok(mut list) => {
+ // Build asset label
+ for row in &mut list {
+ if let Some(obj) = row.as_object_mut() {
+ let asset = match (
+ obj.get("asset_tag").and_then(|v| v.as_str()),
+ obj.get("asset_name").and_then(|v| v.as_str()),
+ ) {
+ (Some(tag), Some(name)) if !tag.is_empty() => {
+ format!("{} ({})", name, tag)
+ }
+ (_, Some(name)) => name.to_string(),
+ _ => "-".to_string(),
+ };
+ obj.insert("asset_label".into(), serde_json::json!(asset));
+ }
+ }
+ self.rows = list;
+ }
+ Err(e) => self.last_error = Some(e.to_string()),
+ }
+ self.is_loading = false;
+ self.init_loaded = true;
+ self.recompute_summary();
+ }
+
+ pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+ ui.horizontal(|ui| {
+ ui.heading("Issues");
+ if self.is_loading {
+ ui.spinner();
+ ui.label("Loading...");
+ }
+ if let Some(err) = &self.last_error {
+ ui.colored_label(egui::Color32::RED, err);
+ if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ } else if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ ui.separator();
+ let button_text = if self.show_column_panel {
+ "Hide Column Selector"
+ } else {
+ "Show Column Selector"
+ };
+ if ui.button(button_text).clicked() {
+ self.show_column_panel = !self.show_column_panel;
+ }
+ });
+ ui.separator();
+
+ if !self.init_loaded {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+
+ // Column selector
+ if self.show_column_panel {
+ self.show_columns_window(ui);
+ }
+
+ // Summary chips (cached)
+ self.render_summary(ui);
+
+ let visible_columns: Vec<ColumnConfig> =
+ self.columns.iter().filter(|c| c.visible).cloned().collect();
+ self.render_table(ui, &visible_columns);
+
+ // Process selection/dialog events
+ let ctx = ui.ctx();
+ // inline selection now, nothing to fetch here
+ if let Some(row_idx) =
+ ctx.data_mut(|d| d.remove_temp::<usize>(egui::Id::new("iss_double_click_idx")))
+ {
+ self.selected_row = Some(row_idx);
+ self.last_click_row = None;
+ self.last_click_time = None;
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_double_click_edit"))
+ }) {
+ self.open_editor_with(&item);
+ ctx.request_repaint();
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_edit"))
+ }) {
+ self.open_editor_with(&item);
+ ctx.request_repaint();
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_delete"))
+ }) {
+ let title = item
+ .get("title")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
+ self.delete_current_id = Some(id);
+ self.delete_dialog.open(title, id.to_string());
+ ctx.request_repaint();
+ }
+
+ if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) {
+ if confirmed {
+ if let (Some(api), Some(id)) = (api_client, self.delete_current_id) {
+ let where_clause = serde_json::json!({"id": id});
+ match api.delete("issue_tracker", where_clause) {
+ Ok(resp) if resp.success => {
+ self.load(api);
+ }
+ Ok(resp) => {
+ self.last_error = Some(format!("Delete failed: {:?}", resp.error));
+ }
+ Err(e) => {
+ self.last_error = Some(format!("Delete error: {}", e));
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(result) = self.edit_dialog.show_editor(ctx) {
+ if let Some(updated) = result {
+ if let (Some(api), Some(id)) = (api_client, self.edit_current_id) {
+ let values = serde_json::Value::Object(updated);
+ let where_clause = serde_json::json!({"id": id});
+ match api.update("issue_tracker", values, where_clause) {
+ Ok(resp) if resp.success => {
+ self.load(api);
+ }
+ Ok(resp) => {
+ self.last_error = Some(format!("Update failed: {:?}", resp.error));
+ }
+ Err(e) => {
+ self.last_error = Some(format!("Update error: {}", e));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fn render_summary(&self, ui: &mut egui::Ui) {
+ ui.horizontal_wrapped(|ui| {
+ for (status, n) in &self.summary_by_status {
+ issues_chip(ui, format!("{}: {}", status, n), color_for_status(status));
+ }
+ ui.separator();
+ for (sev, n) in &self.summary_by_severity {
+ issues_chip(ui, format!("{}: {}", sev, n), color_for_severity(sev));
+ }
+ });
+ ui.add_space(6.0);
+ }
+
+ fn recompute_summary(&mut self) {
+ use std::collections::HashMap;
+ let mut by_status: HashMap<String, i32> = HashMap::new();
+ let mut by_sev: HashMap<String, i32> = HashMap::new();
+ for r in &self.rows {
+ let status = r
+ .get("status")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ let sev = r
+ .get("severity")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ *by_status.entry(status).or_insert(0) += 1;
+ *by_sev.entry(sev).or_insert(0) += 1;
+ }
+ // Stable order for status
+ let status_order = [
+ "Open",
+ "In Progress",
+ "On Hold",
+ "Resolved",
+ "Closed",
+ "Unknown",
+ ];
+ let mut status_vec: Vec<(String, i32)> = by_status.into_iter().collect();
+ status_vec.sort_by(|a, b| {
+ let ia = status_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&a.0))
+ .unwrap_or(status_order.len());
+ let ib = status_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&b.0))
+ .unwrap_or(status_order.len());
+ ia.cmp(&ib)
+ .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
+ });
+ // Stable order for severity
+ let sev_order = ["Critical", "High", "Medium", "Low", "Unknown"];
+ let mut sev_vec: Vec<(String, i32)> = by_sev.into_iter().collect();
+ sev_vec.sort_by(|a, b| {
+ let ia = sev_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&a.0))
+ .unwrap_or(sev_order.len());
+ let ib = sev_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&b.0))
+ .unwrap_or(sev_order.len());
+ ia.cmp(&ib)
+ .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
+ });
+ self.summary_by_status = status_vec;
+ self.summary_by_severity = sev_vec;
+ }
+
+ fn show_columns_window(&mut self, ui: &egui::Ui) {
+ let ctx = ui.ctx();
+ let screen_rect = ctx.input(|i| {
+ i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max(
+ egui::pos2(0.0, 0.0),
+ egui::pos2(1920.0, 1080.0),
+ ))
+ });
+ let max_w = (screen_rect.width() - 20.0).max(220.0);
+ let max_h = (screen_rect.height() - 100.0).max(200.0);
+
+ egui::Window::new("Column Selector")
+ .collapsible(false)
+ .resizable(true)
+ .default_width(260.0)
+ .default_height(360.0)
+ .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0])
+ .open(&mut self.show_column_panel)
+ .min_size(egui::vec2(220.0, 200.0))
+ .max_size(egui::vec2(max_w, max_h))
+ .frame(egui::Frame {
+ fill: egui::Color32::from_rgb(30, 30, 30),
+ stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
+ inner_margin: egui::Margin::from(10.0),
+ outer_margin: egui::Margin::ZERO,
+ corner_radius: 6.0.into(),
+ shadow: egui::epaint::Shadow::NONE,
+ })
+ .show(ctx, |ui| {
+ ui.heading("Columns");
+ ui.separator();
+ ui.add_space(8.0);
+ egui::ScrollArea::vertical()
+ .auto_shrink([false; 2])
+ .show(ui, |ui| {
+ for column in &mut self.columns {
+ ui.checkbox(&mut column.visible, &column.name);
+ }
+ });
+ ui.add_space(8.0);
+ ui.separator();
+ ui.horizontal(|ui| {
+ if ui.button("Show All").clicked() {
+ for col in &mut self.columns {
+ col.visible = true;
+ }
+ }
+ if ui.button("Hide All").clicked() {
+ for col in &mut self.columns {
+ col.visible = false;
+ }
+ }
+ });
+ });
+ }
+
+ fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) {
+ use egui_extras::{Column, TableBuilder};
+ let mut table = TableBuilder::new(ui)
+ .striped(true)
+ .resizable(true)
+ .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
+ // Add checkbox column first, then the rest
+ table = table.column(Column::initial(28.0));
+ for _ in 0..visible_columns.len() {
+ table = table.column(Column::remainder());
+ }
+ table
+ .header(22.0, |mut header| {
+ // Select-all checkbox
+ header.col(|ui| {
+ let all_selected = self
+ .rows
+ .iter()
+ .enumerate()
+ .all(|(i, _)| self.selected_rows.contains(&i));
+ let mut chk = all_selected;
+ if ui
+ .checkbox(&mut chk, "")
+ .on_hover_text("Select All")
+ .clicked()
+ {
+ if chk {
+ self.selected_rows = (0..self.rows.len()).collect();
+ } else {
+ self.selected_rows.clear();
+ }
+ }
+ });
+ for col in visible_columns {
+ header.col(|ui| {
+ ui.strong(&col.name);
+ });
+ }
+ })
+ .body(|mut body| {
+ for (idx, r) in self.rows.iter().enumerate() {
+ let r_clone = r.clone();
+ let is_selected = self.selected_rows.contains(&idx);
+ body.row(20.0, |mut row| {
+ if is_selected {
+ row.set_selected(true);
+ }
+ // Checkbox cell
+ row.col(|ui| {
+ let mut checked = self.selected_rows.contains(&idx);
+ let resp = ui.checkbox(&mut checked, "");
+ if resp.changed() {
+ let mods = ui.input(|i| i.modifiers);
+ if mods.shift {
+ let anchor = self.selection_anchor.unwrap_or(idx);
+ let (a, b) = if anchor <= idx {
+ (anchor, idx)
+ } else {
+ (idx, anchor)
+ };
+ for i in a..=b {
+ if checked {
+ self.selected_rows.insert(i);
+ } else {
+ self.selected_rows.remove(&i);
+ }
+ }
+ } else if mods.command || mods.ctrl {
+ if checked {
+ self.selected_rows.insert(idx);
+ } else {
+ self.selected_rows.remove(&idx);
+ }
+ self.selection_anchor = Some(idx);
+ } else {
+ self.selected_rows.clear();
+ if checked {
+ self.selected_rows.insert(idx);
+ self.selection_anchor = Some(idx);
+ }
+ }
+ }
+ });
+ // Data columns
+ let mut combined: Option<egui::Response> = None;
+ for col in visible_columns {
+ row.col(|ui| {
+ let resp = render_issue_cell(ui, &r_clone, &col.field);
+ combined = Some(match combined.take() {
+ Some(p) => p.union(resp),
+ None => resp,
+ });
+ });
+ }
+ let mut row_resp = row.response();
+ if let Some(c) = combined {
+ row_resp = row_resp.union(c);
+ }
+ if row_resp.clicked() {
+ let now = std::time::Instant::now();
+ let dbl = if let (Some(t), Some(rw)) =
+ (self.last_click_time, self.last_click_row)
+ {
+ rw == idx && now.duration_since(t).as_millis() < 500
+ } else {
+ false
+ };
+ if dbl {
+ row_resp.ctx.data_mut(|d| {
+ d.insert_temp(egui::Id::new("iss_double_click_idx"), idx);
+ d.insert_temp(
+ egui::Id::new("iss_double_click_edit"),
+ r_clone.clone(),
+ );
+ });
+ } else {
+ // Multi-select on row click
+ let mods = row_resp.ctx.input(|i| i.modifiers);
+ if mods.shift {
+ let anchor = self.selection_anchor.unwrap_or(idx);
+ let (a, b) = if anchor <= idx {
+ (anchor, idx)
+ } else {
+ (idx, anchor)
+ };
+ for i in a..=b {
+ self.selected_rows.insert(i);
+ }
+ } else if mods.command || mods.ctrl {
+ if self.selected_rows.contains(&idx) {
+ self.selected_rows.remove(&idx);
+ } else {
+ self.selected_rows.insert(idx);
+ }
+ self.selection_anchor = Some(idx);
+ } else {
+ self.selected_rows.clear();
+ self.selected_rows.insert(idx);
+ self.selection_anchor = Some(idx);
+ }
+ self.last_click_time = Some(now);
+ self.last_click_row = Some(idx);
+ }
+ }
+ row_resp.context_menu(|ui| {
+ if ui
+ .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
+ .clicked()
+ {
+ ui.ctx().data_mut(|d| {
+ d.insert_temp(
+ egui::Id::new("iss_context_menu_edit"),
+ r_clone.clone(),
+ )
+ });
+ ui.close();
+ }
+ ui.separator();
+ if ui
+ .button(format!("{} Delete", egui_phosphor::regular::TRASH))
+ .clicked()
+ {
+ ui.ctx().data_mut(|d| {
+ d.insert_temp(
+ egui::Id::new("iss_context_menu_delete"),
+ r_clone.clone(),
+ )
+ });
+ ui.close();
+ }
+ });
+ });
+ }
+ });
+ }
+
+ fn open_editor_with(&mut self, item: &serde_json::Value) {
+ self.edit_current_id = item.get("id").and_then(|v| v.as_i64());
+ self.edit_dialog.open(item);
+ }
+}
+
+fn issues_chip(ui: &mut egui::Ui, text: String, color: egui::Color32) {
+ egui::Frame::new()
+ .fill(color.linear_multiply(0.14))
+ .corner_radius(6.0)
+ .inner_margin(egui::Margin {
+ left: 8_i8,
+ right: 8_i8,
+ top: 4_i8,
+ bottom: 4_i8,
+ })
+ .show(ui, |ui| {
+ ui.label(egui::RichText::new(text).color(color));
+ });
+}
+
+fn color_for_status(status: &str) -> egui::Color32 {
+ match status.to_lowercase().as_str() {
+ "open" => egui::Color32::from_rgb(244, 67, 54),
+ "in progress" => egui::Color32::from_rgb(255, 152, 0),
+ "on hold" => egui::Color32::from_rgb(121, 85, 72),
+ "resolved" => egui::Color32::from_rgb(76, 175, 80),
+ "closed" => egui::Color32::from_rgb(96, 125, 139),
+ _ => egui::Color32::GRAY,
+ }
+}
+
+fn color_for_severity(sev: &str) -> egui::Color32 {
+ match sev.to_lowercase().as_str() {
+ "critical" => egui::Color32::from_rgb(244, 67, 54),
+ "high" => egui::Color32::from_rgb(255, 152, 0),
+ "medium" => egui::Color32::from_rgb(66, 165, 245),
+ "low" => egui::Color32::from_rgb(158, 158, 158),
+ _ => egui::Color32::GRAY,
+ }
+}
+
+fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response {
+ if text.len() > max {
+ let short = format!("{}…", &text[..max]);
+ ui.label(short).on_hover_ui(|ui| {
+ ui.label(text);
+ })
+ } else {
+ ui.label(text)
+ }
+}
+
+fn render_issue_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response {
+ let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or("");
+ match field {
+ "id" => ui.label(
+ row.get("id")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0)
+ .to_string(),
+ ),
+ "title" => label_trunc(ui, t("title"), 60),
+ "status" => ui.label(t("status")),
+ "severity" => ui.label(t("severity")),
+ "priority" => ui.label(t("priority")),
+ "asset_label" => ui.label(t("asset_label")),
+ "borrower_name" => ui.label(t("borrower_name")),
+ "assigned_to_name" => ui.label(t("assigned_to_name")),
+ "auto_detected" => ui.label(
+ if row
+ .get("auto_detected")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ "Yes"
+ } else {
+ "No"
+ },
+ ),
+ "detection_trigger" => label_trunc(ui, t("detection_trigger"), 40),
+ "updated_at" => ui.label(t("updated_at")),
+ "created_at" => ui.label(t("created_at")),
+ "resolved_date" => ui.label(t("resolved_date")),
+ "description" => label_trunc(ui, t("description"), 80),
+ "solution" => label_trunc(ui, t("solution"), 80),
+ "solution_plus" => label_trunc(ui, t("solution_plus"), 80),
+ "replacement_asset_id" => ui.label(
+ row.get("replacement_asset_id")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0)
+ .to_string(),
+ ),
+ "cost" => ui.label(match row.get("cost") {
+ Some(serde_json::Value::Number(n)) => n.to_string(),
+ Some(serde_json::Value::String(s)) => s.clone(),
+ _ => String::new(),
+ }),
+ "notes" => label_trunc(ui, t("notes"), 80),
+ other => ui.label(format!("{}", other)),
+ }
+}