2026-04-18 09:38:16 +08:00
|
|
|
use super::protocol;
|
2026-04-26 19:35:52 +08:00
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
2026-04-18 09:38:16 +08:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PickerRenderRow {
|
|
|
|
|
pub node_id: String,
|
2026-04-26 19:35:52 +08:00
|
|
|
pub parent_node_id: Option<String>,
|
2026-04-18 09:38:16 +08:00
|
|
|
pub title: String,
|
2026-04-26 19:35:52 +08:00
|
|
|
pub depth: u32,
|
|
|
|
|
pub expandable: bool,
|
|
|
|
|
pub expanded: bool,
|
|
|
|
|
pub active: bool,
|
2026-04-18 09:38:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PickerRenderResult {
|
|
|
|
|
pub root_test_id: &'static str,
|
|
|
|
|
pub item_count: usize,
|
|
|
|
|
pub allow_root_pick: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PickerInitialRenderInput {
|
|
|
|
|
pub rows: Vec<PickerRenderRow>,
|
|
|
|
|
pub allow_root_pick: bool,
|
|
|
|
|
pub root_active: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 09:38:16 +08:00
|
|
|
pub fn build_picker_render_result(
|
|
|
|
|
rows: &[PickerRenderRow],
|
|
|
|
|
allow_root_pick: bool,
|
|
|
|
|
) -> PickerRenderResult {
|
|
|
|
|
PickerRenderResult {
|
|
|
|
|
root_test_id: protocol::TEST_ID_TREE_PICKER_ROOT,
|
|
|
|
|
item_count: rows.len(),
|
|
|
|
|
allow_root_pick,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
fn escape_html(input: &str) -> String {
|
|
|
|
|
input
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_picker_row(
|
|
|
|
|
html: &mut String,
|
|
|
|
|
row: &PickerRenderRow,
|
|
|
|
|
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
|
|
|
|
|
) {
|
|
|
|
|
html.push_str(&format!(
|
2026-04-27 10:27:15 +08:00
|
|
|
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
2026-04-26 19:35:52 +08:00
|
|
|
node_id = escape_html(&row.node_id),
|
|
|
|
|
aria_level = row.depth + 1,
|
|
|
|
|
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
|
|
|
|
active = row.active,
|
2026-04-27 10:27:15 +08:00
|
|
|
tabindex = if row.active { "0" } else { "-1" },
|
2026-04-26 19:35:52 +08:00
|
|
|
title = escape_html(&row.title),
|
|
|
|
|
));
|
|
|
|
|
if row.expandable && row.expanded {
|
|
|
|
|
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
|
|
|
|
html.push_str(r#"<ul class="tree-children">"#);
|
|
|
|
|
for child in children {
|
|
|
|
|
render_picker_row(html, child, children_by_parent);
|
|
|
|
|
}
|
|
|
|
|
html.push_str("</ul>");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
html.push_str("</li>");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
|
|
|
|
|
let mut html = String::from(
|
|
|
|
|
r#"<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">"#,
|
|
|
|
|
);
|
|
|
|
|
if input.allow_root_pick {
|
|
|
|
|
html.push_str(&format!(
|
2026-04-27 10:27:15 +08:00
|
|
|
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
2026-04-26 19:35:52 +08:00
|
|
|
focused = input.root_active,
|
2026-04-27 10:27:15 +08:00
|
|
|
tabindex = if input.root_active { "0" } else { "-1" },
|
2026-04-26 19:35:52 +08:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ids = input
|
|
|
|
|
.rows
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|row| row.node_id.clone())
|
|
|
|
|
.collect::<BTreeSet<_>>();
|
|
|
|
|
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PickerRenderRow>>::new();
|
|
|
|
|
for row in &input.rows {
|
|
|
|
|
let parent_id = row
|
|
|
|
|
.parent_node_id
|
|
|
|
|
.as_ref()
|
|
|
|
|
.filter(|parent_id| ids.contains(*parent_id))
|
|
|
|
|
.cloned();
|
|
|
|
|
children_by_parent
|
|
|
|
|
.entry(parent_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(row.clone());
|
|
|
|
|
}
|
|
|
|
|
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
|
|
|
|
for root in &roots {
|
|
|
|
|
render_picker_row(&mut html, root, &children_by_parent);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
html.push_str("</ul>");
|
|
|
|
|
html
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 09:38:16 +08:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
2026-04-26 19:35:52 +08:00
|
|
|
use super::{
|
|
|
|
|
build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput,
|
|
|
|
|
PickerRenderRow,
|
|
|
|
|
};
|
2026-04-18 09:38:16 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
|
|
|
|
|
let file_rows = build_filetree_testids(&[
|
|
|
|
|
FileTreeRenderRow {
|
|
|
|
|
row_id: "doc:page_root".into(),
|
|
|
|
|
row_kind: "document".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
node_id: "page_root".into(),
|
|
|
|
|
parent_node_id: None,
|
2026-04-18 09:38:16 +08:00
|
|
|
title: "首页".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
depth: 0,
|
|
|
|
|
expandable: false,
|
|
|
|
|
expanded: false,
|
|
|
|
|
icon_kind: "page".into(),
|
|
|
|
|
document_id: Some("page_root".into()),
|
|
|
|
|
asset_id: None,
|
|
|
|
|
selected: false,
|
2026-04-18 09:38:16 +08:00
|
|
|
},
|
|
|
|
|
FileTreeRenderRow {
|
|
|
|
|
row_id: "asset:asset_1".into(),
|
|
|
|
|
row_kind: "asset".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
node_id: "asset:asset_1".into(),
|
|
|
|
|
parent_node_id: Some("page_root".into()),
|
2026-04-18 09:38:16 +08:00
|
|
|
title: "附件".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
depth: 1,
|
|
|
|
|
expandable: false,
|
|
|
|
|
expanded: false,
|
|
|
|
|
icon_kind: "file".into(),
|
|
|
|
|
document_id: Some("page_root".into()),
|
|
|
|
|
asset_id: Some("asset_1".into()),
|
|
|
|
|
selected: false,
|
2026-04-18 09:38:16 +08:00
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
let picker = build_picker_render_result(
|
|
|
|
|
&[PickerRenderRow {
|
|
|
|
|
node_id: "page_root".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
parent_node_id: None,
|
2026-04-18 09:38:16 +08:00
|
|
|
title: "首页".into(),
|
2026-04-26 19:35:52 +08:00
|
|
|
depth: 0,
|
|
|
|
|
expandable: false,
|
|
|
|
|
expanded: false,
|
|
|
|
|
active: false,
|
2026-04-18 09:38:16 +08:00
|
|
|
}],
|
|
|
|
|
true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(file_rows[0].0, "filetree-doc-row");
|
|
|
|
|
assert_eq!(file_rows[1].0, "filetree-asset-row");
|
|
|
|
|
assert_eq!(picker.root_test_id, "tree-picker-root");
|
|
|
|
|
assert!(picker.allow_root_pick);
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tree_shell_picker_renderer_outputs_initial_html_contract() {
|
|
|
|
|
let html = render_initial_picker_html(&PickerInitialRenderInput {
|
|
|
|
|
allow_root_pick: true,
|
|
|
|
|
root_active: false,
|
|
|
|
|
rows: vec![PickerRenderRow {
|
|
|
|
|
node_id: "page_root".into(),
|
|
|
|
|
parent_node_id: None,
|
|
|
|
|
title: "首页 <安全>".into(),
|
|
|
|
|
depth: 0,
|
|
|
|
|
expandable: false,
|
|
|
|
|
expanded: false,
|
|
|
|
|
active: true,
|
2026-04-27 10:27:15 +08:00
|
|
|
}, PickerRenderRow {
|
|
|
|
|
node_id: "page_other".into(),
|
|
|
|
|
parent_node_id: None,
|
|
|
|
|
title: "其他页面".into(),
|
|
|
|
|
depth: 0,
|
|
|
|
|
expandable: false,
|
|
|
|
|
expanded: false,
|
|
|
|
|
active: false,
|
2026-04-26 19:35:52 +08:00
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
|
|
|
|
|
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
|
|
|
|
|
assert!(html.contains("data-rust-rendered-row=\"picker\""));
|
|
|
|
|
assert!(html.contains("data-testid=\"tree-picker-root\""));
|
|
|
|
|
assert!(html.contains("data-testid=\"tree-picker-row\""));
|
|
|
|
|
assert!(html.contains("首页 <安全>"));
|
|
|
|
|
assert!(html.contains("data-focused=\"true\""));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(html.contains("tabindex=\"0\""));
|
|
|
|
|
assert!(html.contains("tabindex=\"-1\""));
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
2026-04-18 09:38:16 +08:00
|
|
|
}
|