Add Function Components Example (#2088)

* feat(examples): add function components todo example

* chore(examples): apply feedback for function components example

* chore(examples): apply more feedback for function components example

* feat(examples): implement custom hook for edit boolean toggle

* chore(examples): prep for merge, add more documentation

* chore(examples): add more descriptive comment to function component hook, fix cargo.toml
This commit is contained in:
Drew Hutton (Yoroshi) 2021-10-17 05:35:06 +10:30 committed by GitHub
parent 35e1ba60aa
commit 05728e1001
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 597 additions and 0 deletions

View File

@ -15,6 +15,7 @@ members = [
"examples/crm", "examples/crm",
"examples/dyn_create_destroy_apps", "examples/dyn_create_destroy_apps",
"examples/file_upload", "examples/file_upload",
"examples/function_todomvc",
"examples/futures", "examples/futures",
"examples/game_of_life", "examples/game_of_life",
"examples/inner_html", "examples/inner_html",

View File

@ -33,6 +33,7 @@ As an example, check out the TodoMVC example here: <https://examples.yew.rs/todo
| [crm](crm) | Shallow customer relationship management tool | | [crm](crm) | Shallow customer relationship management tool |
| [dyn_create_destroy_apps](dyn_create_destroy_apps) | Uses the function `start_app_in_element` and the `AppHandle` struct to dynamically create and delete Yew apps | | [dyn_create_destroy_apps](dyn_create_destroy_apps) | Uses the function `start_app_in_element` and the `AppHandle` struct to dynamically create and delete Yew apps |
| [file_upload](file_upload) | Uses the `gloo::file` to read the content of user uploaded files | | [file_upload](file_upload) | Uses the `gloo::file` to read the content of user uploaded files |
| [function_todomvc](function_todomvc) | Implementation of [TodoMVC](http://todomvc.com/) using function components and hooks. |
| [futures](futures) | Demonstrates how you can use futures and async code with Yew. Features a Markdown renderer. | | [futures](futures) | Demonstrates how you can use futures and async code with Yew. Features a Markdown renderer. |
| [game_of_life](game_of_life) | Implementation of [Conway's Game of Life](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life) | | [game_of_life](game_of_life) | Implementation of [Conway's Game of Life](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life) |
| [inner_html](inner_html) | Embeds an external document as raw HTML by manually managing the element | | [inner_html](inner_html) | Embeds an external document as raw HTML by manually managing the element |

View File

@ -0,0 +1,19 @@
[package]
name = "function_todomvc"
version = "0.1.0"
authors = ["Drew Hutton <drew.hutton@pm.me>"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
strum = "0.21"
strum_macros = "0.21"
gloo = "0.3"
yew = { path = "../../packages/yew" }
[dependencies.web-sys]
version = "0.3"
features = [
"HtmlInputElement",
]

View File

@ -0,0 +1,15 @@
# TodoMVC Example
[![Demo](https://img.shields.io/website?label=demo&url=https%3A%2F%2Fexamples.yew.rs%2Ffunction_todomvc)](https://examples.yew.rs/function_todomvc)
This is an implementation of [TodoMVC](http://todomvc.com/) for Yew using function components and hooks.
## Concepts
- Uses [`function_components`](https://yew.rs/next/concepts/function-components)
- Uses [`gloo_storage`](https://gloo-rs.web.app/docs/storage) to persist the state
## Improvements
- Use `yew-router` for the hash based routing
- Clean up the code

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Yew • Function TodoMVC</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.3.0/index.css"
/>
</head>
<body></body>
</html>

View File

@ -0,0 +1,4 @@
pub mod entry;
pub mod filter;
pub mod header_input;
pub mod info_footer;

View File

@ -0,0 +1,117 @@
use crate::hooks::use_bool_toggle::use_bool_toggle;
use crate::state::Entry as Item;
use web_sys::{HtmlInputElement, MouseEvent};
use yew::events::{Event, FocusEvent, KeyboardEvent};
use yew::{function_component, html, Callback, Classes, Properties, TargetCast};
#[derive(PartialEq, Properties, Clone)]
pub struct EntryProps {
pub entry: Item,
pub ontoggle: Callback<usize>,
pub onremove: Callback<usize>,
pub onedit: Callback<(usize, String)>,
}
#[function_component(Entry)]
pub fn entry(props: &EntryProps) -> Html {
let id = props.entry.id;
let mut class = Classes::from("todo");
// We use the `use_bool_toggle` hook and set the default value to `false`
// as the default we are not editing the the entry. When we want to edit the
// entry we can call the toggle method on the `UseBoolToggleHandle`
// which will trigger a re-render with the toggle value being `true` for that
// render and after that render the value of toggle will be flipped back to
// its default (`false`).
// We are relying on the behavior of `onblur` and `onkeypress` to cause
// another render so that this component will render again with the
// default value of toggle.
let edit_toggle = use_bool_toggle(false);
let is_editing = *edit_toggle;
if is_editing {
class.push("editing");
}
if props.entry.completed {
class.push("completed");
}
html! {
<li {class}>
<div class="view">
<input
type="checkbox"
class="toggle"
checked={props.entry.completed}
onclick={props.ontoggle.reform(move |_| id)}
/>
<label ondblclick={Callback::once(move |_| {
edit_toggle.toggle();
})}>
{ &props.entry.description }
</label>
<button class="destroy" onclick={props.onremove.reform(move |_| id)} />
</div>
<EntryEdit entry={props.entry.clone()} onedit={props.onedit.clone()} editing={is_editing} />
</li>
}
}
#[derive(PartialEq, Properties, Clone)]
pub struct EntryEditProps {
pub entry: Item,
pub onedit: Callback<(usize, String)>,
pub editing: bool,
}
#[function_component(EntryEdit)]
pub fn entry_edit(props: &EntryEditProps) -> Html {
let id = props.entry.id;
let target_input_value = |e: &Event| {
let input: HtmlInputElement = e.target_unchecked_into();
input.value()
};
let onblur = {
let edit = props.onedit.clone();
move |e: FocusEvent| {
let value = target_input_value(&e);
edit.emit((id, value))
}
};
let onkeypress = {
let edit = props.onedit.clone();
move |e: KeyboardEvent| {
if e.key() == "Enter" {
let value = target_input_value(&e);
edit.emit((id, value))
}
}
};
let onmouseover = |e: MouseEvent| {
e.target_unchecked_into::<HtmlInputElement>()
.focus()
.unwrap_or_default();
};
if props.editing {
html! {
<input
class="edit"
type="text"
value={props.entry.description.clone()}
{onmouseover}
{onblur}
{onkeypress}
/>
}
} else {
html! { <input type="hidden" /> }
}
}

View File

@ -0,0 +1,31 @@
use crate::state::Filter as FilterEnum;
use yew::{function_component, html, Callback, Properties};
#[derive(PartialEq, Properties)]
pub struct FilterProps {
pub filter: FilterEnum,
pub selected: bool,
pub onset_filter: Callback<FilterEnum>,
}
#[function_component(Filter)]
pub fn filter(props: &FilterProps) -> Html {
let filter = props.filter;
let cls = if props.selected {
"selected"
} else {
"not-selected"
};
html! {
<li>
<a class={cls}
href={props.filter.as_href()}
onclick={props.onset_filter.reform(move |_| filter)}
>
{ props.filter }
</a>
</li>
}
}

View File

@ -0,0 +1,33 @@
use web_sys::HtmlInputElement;
use yew::events::KeyboardEvent;
use yew::{function_component, html, Callback, Properties, TargetCast};
#[derive(PartialEq, Properties, Clone)]
pub struct HeaderInputProps {
pub onadd: Callback<String>,
}
#[function_component(HeaderInput)]
pub fn header_input(props: &HeaderInputProps) -> Html {
let onkeypress = {
let onadd = props.onadd.clone();
move |e: KeyboardEvent| {
if e.key() == "Enter" {
let input: HtmlInputElement = e.target_unchecked_into();
let value = input.value();
input.set_value("");
onadd.emit(value);
}
}
};
html! {
<input
class="new-todo"
placeholder="What needs to be done?"
{onkeypress}
/>
}
}

View File

@ -0,0 +1,12 @@
use yew::{function_component, html};
#[function_component(InfoFooter)]
pub fn info_footer() -> Html {
html! {
<footer class="info">
<p>{ "Double-click to edit a todo" }</p>
<p>{ "Written by " }<a href="https://github.com/Yoroshikun/" target="_blank">{ "Drew Hutton <Yoroshi>" }</a></p>
<p>{ "Part of " }<a href="http://todomvc.com/" target="_blank">{ "TodoMVC" }</a></p>
</footer>
}
}

View File

@ -0,0 +1 @@
pub mod use_bool_toggle;

View File

@ -0,0 +1,70 @@
use std::ops::Deref;
use std::rc::Rc;
use yew::functional::use_hook;
pub struct UseBoolToggleHandle {
value: bool,
toggle: Rc<dyn Fn()>,
}
impl UseBoolToggleHandle {
pub fn toggle(self) {
(self.toggle)()
}
}
impl Deref for UseBoolToggleHandle {
type Target = bool;
fn deref(&self) -> &Self::Target {
&self.value
}
}
/// This hook can be used to cause a re-render with the non-default value, which is
/// then reset to the default value after that render.
///
/// # Arguments
///
/// * `default` - The default value.
///
/// # Example
/// ```
/// use crate::hooks::use_bool_toggle::use_bool_toggle;
/// ...
/// let value = use_bool_toggle(false);
/// ...
/// <button onclick={Callback::once(move |_| {
/// value.toggle();
/// // This will toggle the value to true.
/// // Then render.
/// // Post render it will toggle back to false skipping the render.
/// })}>
/// ...
/// ```
pub fn use_bool_toggle(default: bool) -> UseBoolToggleHandle {
use_hook(
|| default,
move |hook, updater| {
updater.post_render(move |state: &mut bool| {
if *state != default {
*state = default;
}
false
});
let toggle = Rc::new(move || {
updater.callback(move |st: &mut bool| {
*st = !*st;
true
})
});
UseBoolToggleHandle {
value: *hook,
toggle,
}
},
|_| {},
)
}

View File

@ -0,0 +1,236 @@
use gloo::storage::{LocalStorage, Storage};
use state::{Entry, Filter, State};
use std::rc::Rc;
use strum::IntoEnumIterator;
use yew::{classes, function_component, html, use_effect_with_deps, use_reducer, Callback};
mod components;
mod hooks;
mod state;
use components::{
entry::Entry as EntryItem, filter::Filter as FilterItem, header_input::HeaderInput,
info_footer::InfoFooter,
};
pub enum Action {
Add(String),
Edit((usize, String)),
Remove(usize),
SetFilter(Filter),
ToggleAll,
Toggle(usize),
ClearCompleted,
}
const KEY: &str = "yew.functiontodomvc.self";
#[function_component(App)]
fn app() -> Html {
let state = use_reducer(
|prev: Rc<State>, action: Action| match action {
Action::Add(description) => {
let mut entries = prev.entries.clone();
entries.push(Entry {
id: entries.last().map(|entry| entry.id + 1).unwrap_or(1),
description,
completed: false,
});
State {
entries,
filter: prev.filter,
}
}
Action::Remove(id) => {
let mut entries = prev.entries.clone();
entries.retain(|entry| entry.id != id);
State {
entries,
filter: prev.filter,
}
}
Action::Toggle(id) => {
let mut entries = prev.entries.clone();
let entry = entries.iter_mut().find(|entry| entry.id == id);
if let Some(entry) = entry {
entry.completed = !entry.completed;
}
State {
entries,
filter: prev.filter,
}
}
Action::Edit((id, description)) => {
let mut entries = prev.entries.clone();
if description.is_empty() {
entries.retain(|entry| entry.id != id)
}
let entry = entries.iter_mut().find(|entry| entry.id == id);
if let Some(entry) = entry {
entry.description = description;
}
State {
entries,
filter: prev.filter,
}
}
Action::ToggleAll => {
let mut entries = prev.entries.clone();
for entry in &mut entries {
if prev.filter.fits(entry) {
entry.completed = !entry.completed;
}
}
State {
entries,
filter: prev.filter,
}
}
Action::ClearCompleted => {
let mut entries = prev.entries.clone();
entries.retain(|e| Filter::Active.fits(e));
State {
entries,
filter: prev.filter,
}
}
Action::SetFilter(filter) => State {
filter,
entries: prev.entries.clone(),
},
},
// Initial state
State {
entries: LocalStorage::get(KEY).unwrap_or_else(|_| vec![]),
filter: Filter::All, // TODO: get from uri
},
);
// Effect
use_effect_with_deps(
move |state| {
LocalStorage::set(KEY, &state.clone().entries).expect("failed to set");
|| ()
},
state.clone(),
);
// Callbacks
let onremove = {
let state = state.clone();
Callback::from(move |id: usize| state.dispatch(Action::Remove(id)))
};
let ontoggle = {
let state = state.clone();
Callback::from(move |id: usize| state.dispatch(Action::Toggle(id)))
};
let ontoggle_all = {
let state = state.clone();
Callback::from(move |_| state.dispatch(Action::ToggleAll))
};
let onclear_completed = {
let state = state.clone();
Callback::from(move |_| state.dispatch(Action::ClearCompleted))
};
let onedit = {
let state = state.clone();
Callback::from(move |(id, value): (usize, String)| {
state.dispatch(Action::Edit((id, value)));
})
};
let onadd = {
let state = state.clone();
Callback::from(move |value: String| {
state.dispatch(Action::Add(value));
})
};
let onset_filter = {
let state = state.clone();
Callback::from(move |filter: Filter| {
state.dispatch(Action::SetFilter(filter));
})
};
// Helpers
let completed = state
.entries
.iter()
.filter(|entry| Filter::Completed.fits(entry))
.count();
let is_all_completed = state
.entries
.iter()
.all(|e| state.filter.fits(e) & e.completed);
let total = state.entries.len();
let hidden_class = if state.entries.is_empty() {
"hidden"
} else {
""
};
html! {
<div class="todomvc-wrapper">
<section class="todoapp">
<header class="header">
<h1>{ "todos" }</h1>
<HeaderInput {onadd} />
</header>
<section class={classes!("main", hidden_class)}>
<input
type="checkbox"
class="toggle-all"
id="toggle-all"
checked={is_all_completed}
onclick={ontoggle_all}
/>
<label for="toggle-all" />
<ul class="todo-list">
{ for state.entries.iter().filter(|e| state.filter.fits(e)).cloned().map(|entry|
html! {
<EntryItem {entry}
ontoggle={ontoggle.clone()}
onremove={onremove.clone()}
onedit={onedit.clone()}
/>
}) }
</ul>
</section>
<footer class={classes!("footer", hidden_class)}>
<span class="todo-count">
<strong>{ total }</strong>
{ " item(s) left" }
</span>
<ul class="filters">
{ for Filter::iter().map(|filter| {
html! {
<FilterItem {filter}
selected={state.filter == filter}
onset_filter={onset_filter.clone()}
/>
}
}) }
</ul>
<button class="clear-completed" onclick={onclear_completed}>
{ format!("Clear completed ({})", completed) }
</button>
</footer>
</section>
<InfoFooter />
</div>
}
}
fn main() {
yew::start_app::<App>();
}

View File

@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use strum_macros::{EnumIter, ToString};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct State {
pub entries: Vec<Entry>,
pub filter: Filter,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Entry {
pub id: usize,
pub description: String,
pub completed: bool,
}
#[derive(Clone, Copy, Debug, EnumIter, ToString, PartialEq, Serialize, Deserialize)]
pub enum Filter {
All,
Active,
Completed,
}
impl Filter {
pub fn fits(&self, entry: &Entry) -> bool {
match *self {
Filter::All => true,
Filter::Active => !entry.completed,
Filter::Completed => entry.completed,
}
}
pub fn as_href(&self) -> &'static str {
match self {
Filter::All => "#/",
Filter::Active => "#/active",
Filter::Completed => "#/completed",
}
}
}