|
| 1 | +//! The typical TodoMVC app, implemented in Dioxus with stores. Stores let us |
| 2 | +//! share nested reactive state between components. They let us keep our todomvc |
| 3 | +//! state in a single struct without wrapping every type in a signal while still |
| 4 | +//! maintaining fine grained reactivity. |
| 5 | +
|
| 6 | +use dioxus::prelude::*; |
| 7 | +use std::{collections::HashMap, vec}; |
| 8 | + |
| 9 | +const STYLE: Asset = asset!("/examples/assets/todomvc.css"); |
| 10 | + |
| 11 | +/// Deriving the store macro on a struct will automatically generate an extension trait |
| 12 | +/// for Store<TodoState> with method to zoom into the fields of the struct. |
| 13 | +/// |
| 14 | +/// For this struct, the macro derives the following methods for Store<TodoState>: |
| 15 | +/// - `todos(self) -> Store<HashMap<u32, TodoItem>, _>` |
| 16 | +/// - `filter(self) -> Store<FilterState, _>` |
| 17 | +#[derive(Store, PartialEq, Clone, Debug)] |
| 18 | +struct TodoState { |
| 19 | + todos: HashMap<u32, TodoItem>, |
| 20 | + filter: FilterState, |
| 21 | +} |
| 22 | + |
| 23 | +// We can also add custom methods to the store by using the `store` attribute on an impl block. |
| 24 | +// The store attribute turns the impl block into an extension trait for Store<TodoState>. |
| 25 | +// Methods that take &self will automatically get a bound that Lens: Readable<Target = TodoState> |
| 26 | +// Methods that take &mut self will automatically get a bound that Lens: Writable<Target = TodoState> |
| 27 | +#[store] |
| 28 | +impl<Lens> Store<TodoState, Lens> { |
| 29 | + fn active_items(&self) -> Vec<u32> { |
| 30 | + let filter = self.filter().cloned(); |
| 31 | + let mut active_ids: Vec<u32> = self |
| 32 | + .todos() |
| 33 | + .iter() |
| 34 | + .filter_map(|(id, item)| item.active(filter).then_some(id)) |
| 35 | + .collect(); |
| 36 | + active_ids.sort_unstable(); |
| 37 | + active_ids |
| 38 | + } |
| 39 | + |
| 40 | + fn incomplete_count(&self) -> usize { |
| 41 | + self.todos() |
| 42 | + .values() |
| 43 | + .filter(|item| item.incomplete()) |
| 44 | + .count() |
| 45 | + } |
| 46 | + |
| 47 | + fn toggle_all(&mut self) { |
| 48 | + let check = self.incomplete_count() != 0; |
| 49 | + for item in self.todos().values() { |
| 50 | + item.checked().set(check); |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + fn has_todos(&self) -> bool { |
| 55 | + !self.todos().is_empty() |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +#[derive(PartialEq, Eq, Clone, Copy, Debug)] |
| 60 | +enum FilterState { |
| 61 | + All, |
| 62 | + Active, |
| 63 | + Completed, |
| 64 | +} |
| 65 | + |
| 66 | +#[derive(Store, PartialEq, Clone, Debug)] |
| 67 | +struct TodoItem { |
| 68 | + checked: bool, |
| 69 | + contents: String, |
| 70 | +} |
| 71 | + |
| 72 | +impl TodoItem { |
| 73 | + fn new(contents: impl ToString) -> Self { |
| 74 | + Self { |
| 75 | + checked: false, |
| 76 | + contents: contents.to_string(), |
| 77 | + } |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +#[store] |
| 82 | +impl<Lens> Store<TodoItem, Lens> { |
| 83 | + fn complete(&self) -> bool { |
| 84 | + self.checked().cloned() |
| 85 | + } |
| 86 | + |
| 87 | + fn incomplete(&self) -> bool { |
| 88 | + !self.complete() |
| 89 | + } |
| 90 | + |
| 91 | + fn active(&self, filter: FilterState) -> bool { |
| 92 | + match filter { |
| 93 | + FilterState::All => true, |
| 94 | + FilterState::Active => self.incomplete(), |
| 95 | + FilterState::Completed => self.complete(), |
| 96 | + } |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +fn main() { |
| 101 | + dioxus::launch(app); |
| 102 | +} |
| 103 | + |
| 104 | +fn app() -> Element { |
| 105 | + // We store the state of our todo list in a store to use throughout the app. |
| 106 | + let mut todos = use_store(|| TodoState { |
| 107 | + todos: HashMap::new(), |
| 108 | + filter: FilterState::All, |
| 109 | + }); |
| 110 | + |
| 111 | + // We use a simple memo to calculate the number of active todos. |
| 112 | + // Whenever the todos change, the active_todo_count will be recalculated. |
| 113 | + let active_todo_count = use_memo(move || todos.incomplete_count()); |
| 114 | + |
| 115 | + // We use a memo to filter the todos based on the current filter state. |
| 116 | + // Whenever the todos or filter change, the filtered_todos will be recalculated. |
| 117 | + // Note that we're only storing the IDs of the todos, not the todos themselves. |
| 118 | + let filtered_todos = use_memo(move || todos.active_items()); |
| 119 | + |
| 120 | + // Toggle all the todos to the opposite of the current state. |
| 121 | + // If all todos are checked, uncheck them all. If any are unchecked, check them all. |
| 122 | + let toggle_all = move |_| { |
| 123 | + todos.toggle_all(); |
| 124 | + }; |
| 125 | + |
| 126 | + rsx! { |
| 127 | + document::Link { rel: "stylesheet", href: STYLE } |
| 128 | + section { class: "todoapp", |
| 129 | + TodoHeader { todos } |
| 130 | + section { class: "main", |
| 131 | + if todos.has_todos() { |
| 132 | + input { |
| 133 | + id: "toggle-all", |
| 134 | + class: "toggle-all", |
| 135 | + r#type: "checkbox", |
| 136 | + onchange: toggle_all, |
| 137 | + checked: active_todo_count() == 0 |
| 138 | + } |
| 139 | + label { r#for: "toggle-all" } |
| 140 | + } |
| 141 | + |
| 142 | + // Render the todos using the filtered_todos memo |
| 143 | + // We pass the ID along with the hashmap into the TodoEntry component so it can access the todo from the todos store. |
| 144 | + ul { class: "todo-list", |
| 145 | + for id in filtered_todos() { |
| 146 | + TodoEntry { key: "{id}", id, todos } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // We only show the footer if there are todos. |
| 151 | + if todos.has_todos() { |
| 152 | + ListFooter { active_todo_count, todos } |
| 153 | + } |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + // A simple info footer |
| 158 | + footer { class: "info", |
| 159 | + p { "Double-click to edit a todo" } |
| 160 | + p { |
| 161 | + "Created by " |
| 162 | + a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" } |
| 163 | + } |
| 164 | + p { |
| 165 | + "Part of " |
| 166 | + a { href: "http://todomvc.com", "TodoMVC" } |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +#[component] |
| 173 | +fn TodoHeader(mut todos: Store<TodoState>) -> Element { |
| 174 | + let mut draft = use_signal(|| "".to_string()); |
| 175 | + let mut todo_id = use_signal(|| 0); |
| 176 | + |
| 177 | + let onkeydown = move |evt: KeyboardEvent| { |
| 178 | + if evt.key() == Key::Enter && !draft.read().is_empty() { |
| 179 | + let id = todo_id(); |
| 180 | + let todo = TodoItem::new(draft.take()); |
| 181 | + todos.todos().insert(id, todo); |
| 182 | + todo_id += 1; |
| 183 | + } |
| 184 | + }; |
| 185 | + |
| 186 | + rsx! { |
| 187 | + header { class: "header", |
| 188 | + h1 { "todos" } |
| 189 | + input { |
| 190 | + class: "new-todo", |
| 191 | + placeholder: "What needs to be done?", |
| 192 | + value: "{draft}", |
| 193 | + autofocus: "true", |
| 194 | + oninput: move |evt| draft.set(evt.value()), |
| 195 | + onkeydown |
| 196 | + } |
| 197 | + } |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +/// A single todo entry |
| 202 | +/// This takes the ID of the todo and the todos store as props |
| 203 | +/// We can use these together to memoize the todo contents and checked state |
| 204 | +#[component] |
| 205 | +fn TodoEntry(mut todos: Store<TodoState>, id: u32) -> Element { |
| 206 | + let mut is_editing = use_signal(|| false); |
| 207 | + |
| 208 | + // When we get an item out of the store, it will only subscribe to that specific item. |
| 209 | + // Since we only get the single todo item, the component will only rerender when that item changes. |
| 210 | + let entry = todos.todos().get(id).unwrap(); |
| 211 | + let checked = entry.checked(); |
| 212 | + let contents = entry.contents(); |
| 213 | + |
| 214 | + rsx! { |
| 215 | + li { |
| 216 | + // Dioxus lets you use if statements in rsx to conditionally render attributes |
| 217 | + // These will get merged into a single class attribute |
| 218 | + class: if checked() { "completed" }, |
| 219 | + class: if is_editing() { "editing" }, |
| 220 | + |
| 221 | + // Some basic controls for the todo |
| 222 | + div { class: "view", |
| 223 | + input { |
| 224 | + class: "toggle", |
| 225 | + r#type: "checkbox", |
| 226 | + id: "cbg-{id}", |
| 227 | + checked: "{checked}", |
| 228 | + oninput: move |evt| entry.checked().set(evt.checked()) |
| 229 | + } |
| 230 | + label { |
| 231 | + r#for: "cbg-{id}", |
| 232 | + ondoubleclick: move |_| is_editing.set(true), |
| 233 | + onclick: |evt| evt.prevent_default(), |
| 234 | + "{contents}" |
| 235 | + } |
| 236 | + button { |
| 237 | + class: "destroy", |
| 238 | + onclick: move |evt| { |
| 239 | + evt.prevent_default(); |
| 240 | + todos.todos().remove(&id); |
| 241 | + }, |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + // Only render the actual input if we're editing |
| 246 | + if is_editing() { |
| 247 | + input { |
| 248 | + class: "edit", |
| 249 | + value: "{contents}", |
| 250 | + oninput: move |evt| entry.contents().set(evt.value()), |
| 251 | + autofocus: "true", |
| 252 | + onfocusout: move |_| is_editing.set(false), |
| 253 | + onkeydown: move |evt| { |
| 254 | + match evt.key() { |
| 255 | + Key::Enter | Key::Escape | Key::Tab => is_editing.set(false), |
| 256 | + _ => {} |
| 257 | + } |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + } |
| 262 | + } |
| 263 | +} |
| 264 | + |
| 265 | +#[component] |
| 266 | +fn ListFooter(mut todos: Store<TodoState>, active_todo_count: ReadSignal<usize>) -> Element { |
| 267 | + // We use a memo to calculate whether we should show the "Clear completed" button. |
| 268 | + // This will recompute whenever the number of todos change or the checked state of an existing |
| 269 | + // todo changes |
| 270 | + let show_clear_completed = use_memo(move || todos.todos().values().any(|todo| todo.complete())); |
| 271 | + let mut filter = todos.filter(); |
| 272 | + |
| 273 | + rsx! { |
| 274 | + footer { class: "footer", |
| 275 | + span { class: "todo-count", |
| 276 | + strong { "{active_todo_count} " } |
| 277 | + span { |
| 278 | + match active_todo_count() { |
| 279 | + 1 => "item", |
| 280 | + _ => "items", |
| 281 | + } |
| 282 | + " left" |
| 283 | + } |
| 284 | + } |
| 285 | + ul { class: "filters", |
| 286 | + for (state , state_text , url) in [ |
| 287 | + (FilterState::All, "All", "#/"), |
| 288 | + (FilterState::Active, "Active", "#/active"), |
| 289 | + (FilterState::Completed, "Completed", "#/completed"), |
| 290 | + ] { |
| 291 | + li { |
| 292 | + a { |
| 293 | + href: url, |
| 294 | + class: if filter() == state { "selected" }, |
| 295 | + onclick: move |evt| { |
| 296 | + evt.prevent_default(); |
| 297 | + filter.set(state) |
| 298 | + }, |
| 299 | + {state_text} |
| 300 | + } |
| 301 | + } |
| 302 | + } |
| 303 | + } |
| 304 | + if show_clear_completed() { |
| 305 | + button { |
| 306 | + class: "clear-completed", |
| 307 | + onclick: move |_| todos.todos().retain(|_, todo| !todo.checked), |
| 308 | + "Clear completed" |
| 309 | + } |
| 310 | + } |
| 311 | + } |
| 312 | + } |
| 313 | +} |
0 commit comments