Recommended pattern for a dynamic nested data-structure editor with row-level signals? #4697
Answered
by
gbj
CedricGARVENES-Exalt
asked this question in
Q&A
-
|
I’m trying to build a dynamic data-structure editor in Leptos. The concrete use case is a course structure editor: a course contains parts, and each part contains chapters. Users can edit titles inline, add/delete parts, add/delete chapters, and eventually reorder items. I want each input field to update independently while still allowing the parent nested structure to be modified dynamically. Here is a reproduction of my use case (If necessary, I can send you the code for my project) : use bson::oid::ObjectId;
use leptos::prelude::*;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum CourseNodeType {
Part,
Chapter,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CourseNodeOverviewModel {
pub id: ObjectId,
pub course_id: ObjectId,
pub title: String,
pub node_type: CourseNodeType,
pub order: i32,
pub parent_id: Option<ObjectId>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum CourseNodeParentRef {
Persisted(ObjectId),
Local(ObjectId),
}
#[derive(Debug, Clone)]
pub struct DraftCourseNode {
pub course_id: ObjectId,
pub title: String,
pub node_type: CourseNodeType,
pub order: i32,
pub parent_id: Option<CourseNodeParentRef>,
}
#[derive(Debug, Clone)]
pub struct DraftCourseItem {
pub id: CourseNodeParentRef,
pub initial: Option<CourseNodeOverviewModel>,
pub current: RwSignal<DraftCourseNode>,
}
pub type DraftCourseChapter = DraftCourseItem;
#[derive(Debug, Clone)]
pub struct DraftCoursePart {
pub item: DraftCourseItem,
pub chapters: Vec<DraftCourseChapter>,
}
#[derive(Debug, Clone)]
pub struct DraftCourseStructure {
pub parts: Vec<DraftCoursePart>,
pub deleted_ids: Vec<ObjectId>,
}
impl DraftCourseStructure {
pub fn add_part(&mut self, course_id: ObjectId) {
let order = self.parts.len() as i32;
self.parts.push(new_draft_part(course_id, order));
}
pub fn add_chapter(&mut self, course_id: ObjectId, part_index: usize) {
if part_index >= self.parts.len() {
return;
}
let part = &self.parts[part_index];
let parent_id = part.item.id;
let order = part.chapters.len() as i32;
self.parts[part_index]
.chapters
.push(new_draft_chapter(course_id, parent_id, order));
}
pub fn delete_part(&mut self, part_index: usize) {
if part_index >= self.parts.len() {
return;
}
let part = self.parts.remove(part_index);
if let CourseNodeParentRef::Persisted(id) = part.item.id {
self.deleted_ids.push(id);
}
}
pub fn delete_chapter(&mut self, part_index: usize, chapter_index: usize) {
if part_index >= self.parts.len() || chapter_index >= self.parts[part_index].chapters.len()
{
return;
}
let chapter = self.parts[part_index].chapters.remove(chapter_index);
if let CourseNodeParentRef::Persisted(id) = chapter.id {
self.deleted_ids.push(id);
}
}
}
fn draft_node_from_overview(node: &CourseNodeOverviewModel) -> DraftCourseNode {
DraftCourseNode {
course_id: node.course_id,
title: node.title.clone(),
node_type: node.node_type,
order: node.order,
parent_id: node.parent_id.map(CourseNodeParentRef::Persisted),
}
}
pub fn new_draft_part(course_id: ObjectId, order: i32) -> DraftCoursePart {
let client_id = ObjectId::new();
let node = DraftCourseNode {
course_id,
title: "Nouvelle partie".to_string(),
node_type: CourseNodeType::Part,
order,
parent_id: None,
};
DraftCoursePart {
item: DraftCourseItem {
id: CourseNodeParentRef::Local(client_id),
initial: None,
current: RwSignal::new(node),
},
chapters: Vec::new(),
}
}
pub fn new_draft_chapter(
course_id: ObjectId,
parent_id: CourseNodeParentRef,
order: i32,
) -> DraftCourseChapter {
let client_id = ObjectId::new();
let node = DraftCourseNode {
course_id,
title: "Nouveau chapitre".to_string(),
node_type: CourseNodeType::Chapter,
order,
parent_id: Some(parent_id),
};
DraftCourseChapter {
id: CourseNodeParentRef::Local(client_id),
initial: None,
current: RwSignal::new(node),
}
}
pub fn group_nodes_by_part(
nodes: &[CourseNodeOverviewModel],
) -> (
Vec<CourseNodeOverviewModel>,
HashMap<ObjectId, Vec<CourseNodeOverviewModel>>,
) {
let mut parts = nodes
.iter()
.filter(|n| n.node_type == CourseNodeType::Part && n.parent_id.is_none())
.cloned()
.collect::<Vec<_>>();
parts.sort_by_key(|p| p.order);
let mut chapters_by_part: HashMap<_, Vec<_>> = HashMap::new();
for node in nodes.iter() {
if node.node_type == CourseNodeType::Chapter
&& let Some(part_id) = node.parent_id
{
chapters_by_part
.entry(part_id)
.or_default()
.push(node.clone());
}
}
for chapters in chapters_by_part.values_mut() {
chapters.sort_by_key(|ch| ch.order);
}
(parts, chapters_by_part)
}
pub fn build_draft_course_structure(nodes: &[CourseNodeOverviewModel]) -> DraftCourseStructure {
let (parts, mut chapters_by_part) = group_nodes_by_part(nodes);
let parts = parts
.into_iter()
.map(|part| {
let chapters = chapters_by_part.remove(&part.id).unwrap_or_default();
let draft_part_node = draft_node_from_overview(&part);
DraftCoursePart {
item: DraftCourseItem {
id: CourseNodeParentRef::Persisted(part.id),
initial: Some(part),
current: RwSignal::new(draft_part_node),
},
chapters: chapters
.into_iter()
.map(|chapter| {
let draft_chapter_node = draft_node_from_overview(&chapter);
DraftCourseChapter {
id: CourseNodeParentRef::Persisted(chapter.id),
initial: Some(chapter),
current: RwSignal::new(draft_chapter_node),
}
})
.collect(),
}
})
.collect();
DraftCourseStructure {
parts,
deleted_ids: Vec::new(),
}
}
fn sample_nodes() -> (ObjectId, Vec<CourseNodeOverviewModel>) {
let course_id = ObjectId::new();
let part_1_id = ObjectId::new();
let part_2_id = ObjectId::new();
(
course_id,
vec![
CourseNodeOverviewModel {
id: part_1_id,
course_id,
title: "Partie 1".to_string(),
node_type: CourseNodeType::Part,
order: 0,
parent_id: None,
},
CourseNodeOverviewModel {
id: ObjectId::new(),
course_id,
title: "Chapitre 1.1".to_string(),
node_type: CourseNodeType::Chapter,
order: 0,
parent_id: Some(part_1_id),
},
CourseNodeOverviewModel {
id: ObjectId::new(),
course_id,
title: "Chapitre 1.2".to_string(),
node_type: CourseNodeType::Chapter,
order: 1,
parent_id: Some(part_1_id),
},
CourseNodeOverviewModel {
id: part_2_id,
course_id,
title: "Partie 2".to_string(),
node_type: CourseNodeType::Part,
order: 1,
parent_id: None,
},
CourseNodeOverviewModel {
id: ObjectId::new(),
course_id,
title: "Chapitre 2.1".to_string(),
node_type: CourseNodeType::Chapter,
order: 0,
parent_id: Some(part_2_id),
},
],
)
}
#[component]
fn App() -> impl IntoView {
console_error_panic_hook::set_once();
let (course_id, nodes) = sample_nodes();
let draft_structure = RwSignal::new(Some(build_draft_course_structure(&nodes)));
view! {
<main style="font-family: sans-serif; max-width: 900px; margin: 24px auto;">
<h1>"Course structure repro"</h1>
<button
on:click=move |_| {
draft_structure.update(|draft| {
if let Some(draft) = draft {
draft.add_part(course_id);
}
});
}
>
"Ajouter une partie"
</button>
{move || match draft_structure.get() {
None => view! { <p>"Loading..."</p> }.into_any(),
Some(_) => view! {
<For
each=move || {
draft_structure
.get()
.map(|draft| {
draft.parts
.into_iter()
.enumerate()
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
key=|(_, part)| part.item.id
children=move |(part_index, part)| {
view! {
<PartView
course_id=course_id
part_index=part_index
part=part
draft_structure=draft_structure
/>
}
}
/>
}.into_any(),
}}
</main>
}
}
#[component]
fn PartView(
course_id: ObjectId,
part_index: usize,
part: DraftCoursePart,
draft_structure: RwSignal<Option<DraftCourseStructure>>,
) -> impl IntoView {
view! {
<section style="border: 1px solid #bbb; padding: 12px; margin-top: 16px;">
<header style="display: flex; gap: 8px; align-items: center;">
<input
prop:value=move || part.item.current.get().title
on:input=move |ev| {
let title = event_target_value(&ev);
part.item.current.update(|node| node.title = title);
}
/>
<button
on:click=move |_| {
draft_structure.update(|draft| {
if let Some(draft) = draft {
draft.add_chapter(course_id, part_index);
}
});
}
>
"Ajouter un chapitre"
</button>
<button
on:click=move |_| {
draft_structure.update(|draft| {
if let Some(draft) = draft {
draft.delete_part(part_index);
}
});
}
>
"Supprimer la partie"
</button>
</header>
<ul>
<For
each=move || {
draft_structure
.get()
.and_then(|draft| {
draft.parts.get(part_index).map(|part| {
part.chapters
.clone()
.into_iter()
.enumerate()
.collect::<Vec<_>>()
})
})
.unwrap_or_default()
}
key=|(_, chapter)| chapter.id
children=move |(chapter_index, chapter)| {
view! {
<ChapterView
part_index=part_index
chapter_index=chapter_index
chapter=chapter
draft_structure=draft_structure
/>
}
}
/>
</ul>
</section>
}
}
#[component]
fn ChapterView(
part_index: usize,
chapter_index: usize,
chapter: DraftCourseChapter,
draft_structure: RwSignal<Option<DraftCourseStructure>>,
) -> impl IntoView {
view! {
<li style="display: flex; gap: 8px; margin-top: 8px;">
<input
prop:value=move || chapter.current.get().title
on:input=move |ev| {
let title = event_target_value(&ev);
chapter.current.update(|node| node.title = title);
}
/>
<button
on:click=move |_| {
draft_structure.update(|draft| {
if let Some(draft) = draft {
draft.delete_chapter(part_index, chapter_index);
}
});
}
>
"Supprimer le chapitre"
</button>
</li>
}
}
fn main() {
mount_to_body(App);
} |
Beta Was this translation helpful? Give feedback.
Answered by
gbj
May 4, 2026
Replies: 1 comment 6 replies
-
|
You should use a Store.
|
Beta Was this translation helpful? Give feedback.
6 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Ok, here's a minimal reproduction