feat: complete tree shell cutover and regression coverage
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TreeShellCommandAction {
|
||||
Create,
|
||||
Rename,
|
||||
Move,
|
||||
Archive,
|
||||
Restore,
|
||||
Purge,
|
||||
Embed,
|
||||
Copy,
|
||||
}
|
||||
|
||||
impl TreeShellCommandAction {
|
||||
pub fn preferred_command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "tree.node.create",
|
||||
Self::Rename => "tree.node.rename",
|
||||
Self::Move => "tree.subtree.move",
|
||||
Self::Archive => "tree.node.archive",
|
||||
Self::Restore => "tree.node.restore",
|
||||
Self::Purge => "tree.node.purge",
|
||||
Self::Embed => "tree.node.embed",
|
||||
Self::Copy => "tree.subtree.copy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compat_command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "documents.create",
|
||||
Self::Rename => "documents.title.update",
|
||||
Self::Move => "documents.move",
|
||||
Self::Archive => "documents.delete",
|
||||
Self::Restore => "documents.restore",
|
||||
Self::Purge => "documents.purge",
|
||||
Self::Embed => "documents.embed",
|
||||
Self::Copy => "documents.copy_tree",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTreeRenderRow {
|
||||
pub row_id: String,
|
||||
pub row_kind: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let test_id = match row.row_kind.as_str() {
|
||||
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
|
||||
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
|
||||
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
|
||||
};
|
||||
(test_id, row.row_id.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeShellBootstrap {
|
||||
pub workspace_id: String,
|
||||
pub root_node_id: Option<String>,
|
||||
pub mode: String,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeShellLoader {
|
||||
pub bootstrap: TreeShellBootstrap,
|
||||
}
|
||||
|
||||
impl TreeShellLoader {
|
||||
pub fn from_bootstrap(bootstrap: TreeShellBootstrap) -> Self {
|
||||
Self { bootstrap }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
pub mod dispatcher;
|
||||
pub mod filetree_renderer;
|
||||
pub mod loader;
|
||||
pub mod page_renderer;
|
||||
pub mod picker_renderer;
|
||||
pub mod protocol;
|
||||
pub mod state;
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn TreeShellRoot(mode: &'static str, headline: String) -> impl IntoView {
|
||||
view! {
|
||||
<section data-testid="tree-shell-root" data-mode=mode>
|
||||
<header>
|
||||
<p>"mnote tree shell"</p>
|
||||
<h1>{headline}</h1>
|
||||
</header>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dispatcher::TreeShellCommandAction;
|
||||
use super::loader::{TreeShellBootstrap, TreeShellLoader};
|
||||
use super::protocol;
|
||||
use super::state::TreeShellUiState;
|
||||
|
||||
#[test]
|
||||
fn tree_shell_leptos_scaffold_exposes_loader_dispatcher_and_state() {
|
||||
let bootstrap = TreeShellBootstrap {
|
||||
workspace_id: "ws_demo".into(),
|
||||
root_node_id: Some("page_root".into()),
|
||||
mode: protocol::MODE_PAGE.into(),
|
||||
request_id: "req_demo".into(),
|
||||
trace_id: "trace_demo".into(),
|
||||
};
|
||||
let loader = TreeShellLoader::from_bootstrap(bootstrap.clone());
|
||||
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
|
||||
state.select("page_root");
|
||||
state.focus("page_root");
|
||||
|
||||
assert_eq!(loader.bootstrap.workspace_id, "ws_demo");
|
||||
assert_eq!(
|
||||
TreeShellCommandAction::Create.preferred_command_name(),
|
||||
"tree.node.create"
|
||||
);
|
||||
assert!(state.expanded.contains("page_root"));
|
||||
assert_eq!(state.selected.as_deref(), Some("page_root"));
|
||||
assert_eq!(state.focused.as_deref(), Some("page_root"));
|
||||
assert_eq!(bootstrap.mode, protocol::MODE_PAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeRenderRow {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
pub depth: u32,
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PageTreeDomRow {
|
||||
pub test_id: &'static str,
|
||||
pub action_create_test_id: &'static str,
|
||||
pub action_rename_test_id: &'static str,
|
||||
pub action_move_up_test_id: &'static str,
|
||||
pub node_id: String,
|
||||
pub depth: u32,
|
||||
pub title: String,
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
|
||||
rows.iter()
|
||||
.map(|row| PageTreeDomRow {
|
||||
test_id: protocol::TEST_ID_TREE_NODE_OPEN,
|
||||
action_create_test_id: protocol::TEST_ID_TREE_ACTION_CREATE,
|
||||
action_rename_test_id: protocol::TEST_ID_TREE_ACTION_RENAME,
|
||||
action_move_up_test_id: protocol::TEST_ID_TREE_ACTION_MOVE_UP,
|
||||
node_id: row.node_id.clone(),
|
||||
depth: row.depth,
|
||||
title: row.title.clone(),
|
||||
expandable: row.expandable,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
|
||||
|
||||
#[test]
|
||||
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
|
||||
let rows = build_page_tree_dom_rows(&[
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
title: "首页".into(),
|
||||
depth: 0,
|
||||
expandable: true,
|
||||
},
|
||||
PageTreeRenderRow {
|
||||
node_id: "page_child".into(),
|
||||
title: "子页".into(),
|
||||
depth: 1,
|
||||
expandable: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].test_id, "tree-node-open");
|
||||
assert_eq!(rows[0].action_create_test_id, "tree-action-create");
|
||||
assert_eq!(rows[0].action_rename_test_id, "tree-action-rename");
|
||||
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
|
||||
assert_eq!(rows[1].depth, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use super::protocol;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRenderRow {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerRenderResult {
|
||||
pub root_test_id: &'static str,
|
||||
pub item_count: usize,
|
||||
pub allow_root_pick: bool,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
||||
use super::{build_picker_render_result, PickerRenderRow};
|
||||
|
||||
#[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(),
|
||||
title: "首页".into(),
|
||||
},
|
||||
FileTreeRenderRow {
|
||||
row_id: "asset:asset_1".into(),
|
||||
row_kind: "asset".into(),
|
||||
title: "附件".into(),
|
||||
},
|
||||
]);
|
||||
let picker = build_picker_render_result(
|
||||
&[PickerRenderRow {
|
||||
node_id: "page_root".into(),
|
||||
title: "首页".into(),
|
||||
}],
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub const MODE_PAGE: &str = "page";
|
||||
pub const MODE_FILETREE: &str = "filetree";
|
||||
pub const MODE_PICKER: &str = "picker";
|
||||
|
||||
pub const TEST_ID_TREE_SHELL_ROOT: &str = "tree-shell-root";
|
||||
pub const TEST_ID_TREE_NODE_OPEN: &str = "tree-node-open";
|
||||
pub const TEST_ID_TREE_NODE_TOGGLE: &str = "tree-node-toggle";
|
||||
pub const TEST_ID_TREE_ACTION_CREATE: &str = "tree-action-create";
|
||||
pub const TEST_ID_TREE_ACTION_RENAME: &str = "tree-action-rename";
|
||||
pub const TEST_ID_TREE_ACTION_MOVE_UP: &str = "tree-action-move-up";
|
||||
pub const TEST_ID_TREE_PICKER_ROOT: &str = "tree-picker-root";
|
||||
pub const TEST_ID_FILETREE_DOC_ROW: &str = "filetree-doc-row";
|
||||
pub const TEST_ID_FILETREE_INDEX_ROW: &str = "filetree-index-row";
|
||||
pub const TEST_ID_FILETREE_ASSET_ROW: &str = "filetree-asset-row";
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TreeShellUiState {
|
||||
pub expanded: BTreeSet<String>,
|
||||
pub selected: Option<String>,
|
||||
pub focused: Option<String>,
|
||||
pub keyboard_anchor: Option<String>,
|
||||
pub dragging: Option<String>,
|
||||
pub drop_target: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeShellUiState {
|
||||
pub fn from_defaults(expanded: &[String]) -> Self {
|
||||
Self {
|
||||
expanded: expanded.iter().cloned().collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_expanded(&mut self, node_id: &str) {
|
||||
if !self.expanded.insert(node_id.to_string()) {
|
||||
self.expanded.remove(node_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(&mut self, node_id: &str) {
|
||||
self.selected = Some(node_id.to_string());
|
||||
self.keyboard_anchor = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, node_id: &str) {
|
||||
self.focused = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn start_drag(&mut self, node_id: &str) {
|
||||
self.dragging = Some(node_id.to_string());
|
||||
}
|
||||
|
||||
pub fn set_drop_target(&mut self, node_id: Option<&str>) {
|
||||
self.drop_target = node_id.map(ToOwned::to_owned);
|
||||
}
|
||||
|
||||
pub fn clear_drag(&mut self) {
|
||||
self.dragging = None;
|
||||
self.drop_target = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TreeShellUiState;
|
||||
|
||||
#[test]
|
||||
fn tree_shell_interaction_state_tracks_selection_focus_keyboard_and_dragging() {
|
||||
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
|
||||
state.toggle_expanded("page_child");
|
||||
state.select("page_child");
|
||||
state.focus("page_child");
|
||||
state.start_drag("page_child");
|
||||
state.set_drop_target(Some("page_root"));
|
||||
|
||||
assert!(state.expanded.contains("page_root"));
|
||||
assert!(state.expanded.contains("page_child"));
|
||||
assert_eq!(state.selected.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.focused.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.keyboard_anchor.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.dragging.as_deref(), Some("page_child"));
|
||||
assert_eq!(state.drop_target.as_deref(), Some("page_root"));
|
||||
|
||||
state.clear_drag();
|
||||
assert!(state.dragging.is_none());
|
||||
assert!(state.drop_target.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user