yggdrasil/components/
sql_result_table.rs1use dioxus::prelude::*;
10
11use crate::api::database::sql_console::SqlResult;
12use crate::components::ui::ADMIN_TABLE_CLASS;
13
14const EXPAND_MAX_HEIGHT_CLASS: &str = "max-h-80";
16const TEXT_CELL_MAX_WIDTH_CLASS: &str = "max-w-[24rem]";
18
19fn render_cell(value: &serde_json::Value) -> Element {
25 use serde_json::Value;
26 match value {
27 Value::Null => rsx! {
28 span { class: "italic text-[var(--color-paper-tertiary)] select-none", "NULL" }
29 },
30 Value::Bool(true) => rsx! {
31 span {
32 class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium",
33 style: "background-color: var(--color-paper-accent-soft); color: var(--color-paper-accent);",
34 "true"
35 }
36 },
37 Value::Bool(false) => rsx! {
38 span {
39 class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-amber-500/15 text-amber-700 dark:text-amber-400",
40 "false"
41 }
42 },
43 Value::Number(n) => rsx! {
44 span { class: "block text-right tabular-nums font-mono text-xs text-[var(--color-paper-primary)]",
45 "{n}"
46 }
47 },
48 Value::String(s) => rsx! {
49 span { class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
50 "{s}"
51 }
52 },
53 other => rsx! {
55 span { class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
56 "{other}"
57 }
58 },
59 }
60}
61
62fn render_expanded_value(col: &str, value: &serde_json::Value) -> Element {
67 use serde_json::Value;
68 let (display, is_null) = match value {
69 Value::Null => ("NULL".to_string(), true),
70 Value::Bool(b) => (b.to_string(), false),
71 Value::Number(n) => (n.to_string(), false),
72 Value::String(s) => (s.clone(), false),
73 other => (other.to_string(), false),
74 };
75 rsx! {
76 div { key: "{col}", class: "flex items-baseline gap-3 py-1 border-b border-[var(--color-paper-border)]/30 last:border-0",
77 span { class: "shrink-0 font-mono text-xs font-medium text-[var(--color-paper-tertiary)] min-w-[7rem] select-none",
78 "{col}"
79 }
80 if is_null {
81 span { class: "font-mono text-xs italic text-[var(--color-paper-tertiary)] select-none", "NULL" }
82 } else {
83 span { class: "font-mono text-xs text-[var(--color-paper-primary)] select-all", "{display}" }
84 }
85 }
86 }
87}
88
89#[derive(Props, Clone, PartialEq)]
91pub struct SqlResultTableProps {
92 pub result: SqlResult,
93 #[props(default)]
94 pub initial_expanded: Option<usize>,
95}
96
97#[component]
103#[allow(non_snake_case)]
104#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
105pub fn SqlResultTable(props: SqlResultTableProps) -> Element {
106 let initial = props.initial_expanded;
107 let mut expanded_row: Signal<Option<usize>> = use_signal(move || initial);
108 let cols_len = props.result.columns.len();
109 let expand_colspan = cols_len + 1;
111
112 rsx! {
113 div { class: "{ADMIN_TABLE_CLASS}",
114 div { class: "overflow-auto max-h-[70vh]",
115 table { class: "w-full text-sm border-collapse",
116 thead {
117 tr { class: "border-b border-[var(--color-paper-border)] sticky top-0 bg-[var(--color-paper-entry)] z-10",
118 th { class: "w-8 px-2 py-2.5 text-center", "" }
119 for col in props.result.columns.iter() {
120 th {
121 key: "{col}",
122 class: "px-4 py-2.5 text-left font-mono text-xs font-semibold tracking-wider text-[var(--color-paper-secondary)] whitespace-nowrap",
123 "{col}"
124 }
125 }
126 }
127 }
128 tbody {
129 for (row_idx, row) in props.result.rows.iter().enumerate() {
130 tr {
132 key: "row-{row_idx}",
133 class: "group border-b border-[var(--color-paper-border)] last:border-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer",
134 onclick: move |_| {
135 let cur = expanded_row();
136 if cur == Some(row_idx) {
137 expanded_row.set(None);
138 } else {
139 expanded_row.set(Some(row_idx));
140 }
141 },
142 td { class: "w-8 px-2 py-2.5 text-center text-[var(--color-paper-tertiary)] text-xs select-none",
143 span {
144 class: format!(
145 "inline-block transition-transform duration-150 text-[10px] {}",
146 if expanded_row() == Some(row_idx) { "rotate-90 text-[var(--color-paper-accent)]" } else { "text-[var(--color-paper-tertiary)] group-hover:text-[var(--color-paper-secondary)]" }
147 ),
148 "▶"
149 }
150 }
151 for (ci, cell) in row.iter().enumerate() {
152 td { key: "{ci}", class: "px-4 py-2.5 align-middle", {render_cell(cell)} }
153 }
154 }
155 if expanded_row() == Some(row_idx) {
157 tr {
158 key: "row-{row_idx}-detail",
159 td {
160 colspan: "{expand_colspan}",
161 class: "px-5 py-3.5 bg-[var(--color-paper-theme)]/80 border-b border-[var(--color-paper-border)] shadow-inner",
162 div { class: "{EXPAND_MAX_HEIGHT_CLASS} overflow-y-auto space-y-1 whitespace-pre-wrap break-all",
163 for (ci, col) in props.result.columns.iter().enumerate() {
164 {render_expanded_value(col, row.get(ci).unwrap_or(&serde_json::Value::Null))}
165 }
166 }
167 }
168 }
169 }
170 }
171 }
172 }
173 }
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn props_default_initial_expanded_is_none() {
184 let result = SqlResult {
185 columns: vec!["id".to_string()],
186 rows: vec![vec![serde_json::json!(1)]],
187 statement_type: "SELECT".to_string(),
188 ..Default::default()
189 };
190 let props = SqlResultTableProps {
191 result,
192 initial_expanded: None,
193 };
194 assert_eq!(props.initial_expanded, None);
195 }
196}