mirror of
https://github.com/yewstack/yew.git
synced 2025-12-08 21:26:25 +00:00
website: make tutorial testable (#3879)
This commit is contained in:
parent
7ff45d3198
commit
4f3b85e31d
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -3904,7 +3904,9 @@ dependencies = [
|
|||||||
"derive_more",
|
"derive_more",
|
||||||
"glob",
|
"glob",
|
||||||
"gloo",
|
"gloo",
|
||||||
|
"gloo-net 0.6.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
publish = false
|
publish = false
|
||||||
rust-version = "1.62"
|
rust-version = "1.81"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
yew-agent = { path = "../../packages/yew-agent/" }
|
yew-agent = { path = "../../packages/yew-agent/" }
|
||||||
@ -12,7 +12,9 @@ yew-agent = { path = "../../packages/yew-agent/" }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
derive_more = { version = "2.0", features = ["from"] }
|
derive_more = { version = "2.0", features = ["from"] }
|
||||||
gloo = "0.11"
|
gloo = "0.11"
|
||||||
|
gloo-net = "0.6"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
weblog = "0.3.0"
|
weblog = "0.3.0"
|
||||||
|
|||||||
@ -1,46 +1,141 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt::{self, Write};
|
use std::error::Error;
|
||||||
|
use std::fmt::Write;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
use glob::glob;
|
use glob::glob;
|
||||||
|
|
||||||
|
type Result<T = ()> = core::result::Result<T, Box<dyn Error + 'static>>;
|
||||||
|
|
||||||
|
macro_rules! e {
|
||||||
|
($($fmt:tt),* $(,)?) => {
|
||||||
|
return Err(format!($($fmt),*).into())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert {
|
||||||
|
($condition:expr, $($fmt:tt),* $(,)?) => {
|
||||||
|
if !$condition { e!($($fmt),*) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Level {
|
struct Level {
|
||||||
nested: HashMap<String, Level>,
|
nested: HashMap<String, Level>,
|
||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn should_combine_code_blocks(path: &Path) -> io::Result<bool> {
|
||||||
let home = env::var("CARGO_MANIFEST_DIR").unwrap();
|
const FLAG: &[u8] = b"<!-- COMBINE CODE BLOCKS -->";
|
||||||
let pattern = format!("{home}/../../website/docs/**/*.md*");
|
|
||||||
let base = format!("{home}/../../website");
|
let mut file = File::open(path)?;
|
||||||
let base = Path::new(&base).canonicalize().unwrap();
|
match file.seek(SeekFrom::End(-32)) {
|
||||||
let dir_pattern = format!("{home}/../../website/docs/**");
|
Ok(_) => (),
|
||||||
for dir in glob(&dir_pattern).unwrap() {
|
Err(e) if e.kind() == ErrorKind::InvalidInput => return Ok(false),
|
||||||
println!("cargo:rerun-if-changed={}", dir.unwrap().display());
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
file.read_exact(&mut buf)?;
|
||||||
|
Ok(buf.trim_ascii_end().ends_with(FLAG))
|
||||||
|
}
|
||||||
|
|
||||||
let mut level = Level::default();
|
fn apply_diff(src: &mut String, preamble: &str, added: &str, removed: &str) -> Result {
|
||||||
|
assert!(
|
||||||
|
!preamble.is_empty() || !removed.is_empty(),
|
||||||
|
"Failure on applying a diff: \nNo preamble or text to remove provided, unable to find \
|
||||||
|
location to insert:\n{added}\nIn the following text:\n{src}",
|
||||||
|
);
|
||||||
|
|
||||||
for entry in glob(&pattern).unwrap() {
|
let mut matches = src.match_indices(if preamble.is_empty() {
|
||||||
let path = entry.unwrap();
|
removed
|
||||||
let path = Path::new(&path).canonicalize().unwrap();
|
} else {
|
||||||
println!("cargo:rerun-if-changed={}", path.display());
|
preamble
|
||||||
let rel = path.strip_prefix(&base).unwrap();
|
});
|
||||||
|
let Some((preamble_start, _)) = matches.next() else {
|
||||||
|
e!(
|
||||||
|
"Failure on applying a diff: \ncouldn't find the following text:\n{preamble}\n\nIn \
|
||||||
|
the following text:\n{src}"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let mut parts = vec![];
|
assert!(
|
||||||
|
matches.next().is_none(),
|
||||||
|
"Failure on applying a diff: \nAmbiguous preamble:\n{preamble}\nIn the following \
|
||||||
|
text:\n{src}\nWhile trying to remove the following text:\n{removed}\nAnd add the \
|
||||||
|
following:\n{added}\n"
|
||||||
|
);
|
||||||
|
|
||||||
for part in rel {
|
let preamble_end = preamble_start + preamble.len();
|
||||||
parts.push(part.to_str().unwrap());
|
assert!(
|
||||||
|
src.get(preamble_end..preamble_end + removed.len()) == Some(removed),
|
||||||
|
"Failure on applying a diff: \nText to remove not found:\n{removed}\n\nIn the following \
|
||||||
|
text:\n{src}",
|
||||||
|
);
|
||||||
|
|
||||||
|
src.replace_range(preamble_end..preamble_end + removed.len(), added);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn combined_code_blocks(path: &Path) -> Result<String> {
|
||||||
|
let file = BufReader::new(File::open(path)?);
|
||||||
|
let mut res = String::new();
|
||||||
|
|
||||||
|
let mut err = Ok(());
|
||||||
|
let mut lines = file
|
||||||
|
.lines()
|
||||||
|
.filter_map(|i| i.map_err(|e| err = Err(e)).ok());
|
||||||
|
while let Some(line) = lines.next() {
|
||||||
|
if !line.starts_with("```rust") {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
level.insert(path.clone(), &parts[..]);
|
let mut preamble = String::new();
|
||||||
|
let mut added = String::new();
|
||||||
|
let mut removed = String::new();
|
||||||
|
let mut diff_applied = false;
|
||||||
|
for line in &mut lines {
|
||||||
|
if line.starts_with("```") {
|
||||||
|
if !added.is_empty() || !removed.is_empty() {
|
||||||
|
apply_diff(&mut res, &preamble, &added, &removed)?;
|
||||||
|
} else if !diff_applied {
|
||||||
|
// if no diff markers were found, just add the contents
|
||||||
|
res += &preamble;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} else if let Some(line) = line.strip_prefix('+') {
|
||||||
|
if line.starts_with(char::is_whitespace) {
|
||||||
|
added += " ";
|
||||||
|
}
|
||||||
|
added += line;
|
||||||
|
added += "\n";
|
||||||
|
} else if let Some(line) = line.strip_prefix('-') {
|
||||||
|
if line.starts_with(char::is_whitespace) {
|
||||||
|
removed += " ";
|
||||||
|
}
|
||||||
|
removed += line;
|
||||||
|
removed += "\n";
|
||||||
|
} else if line.trim_ascii() == "// ..." {
|
||||||
|
// disregard the preamble
|
||||||
|
preamble.clear();
|
||||||
|
} else {
|
||||||
|
if !added.is_empty() || !removed.is_empty() {
|
||||||
|
diff_applied = true;
|
||||||
|
apply_diff(&mut res, &preamble, &added, &removed)?;
|
||||||
|
preamble += &added;
|
||||||
|
added.clear();
|
||||||
|
removed.clear();
|
||||||
|
}
|
||||||
|
preamble += &line;
|
||||||
|
preamble += "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let out = format!("{}/website_tests.rs", env::var("OUT_DIR").unwrap());
|
Ok(res)
|
||||||
|
|
||||||
fs::write(out, level.to_contents()).unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Level {
|
impl Level {
|
||||||
@ -53,14 +148,14 @@ impl Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_contents(&self) -> String {
|
fn to_contents(&self) -> Result<String> {
|
||||||
let mut dst = String::new();
|
let mut dst = String::new();
|
||||||
|
|
||||||
self.write_inner(&mut dst, 0).unwrap();
|
self.write_inner(&mut dst, 0)?;
|
||||||
dst
|
Ok(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_into(&self, dst: &mut String, name: &str, level: usize) -> fmt::Result {
|
fn write_into(&self, dst: &mut String, name: &str, level: usize) -> Result {
|
||||||
self.write_space(dst, level);
|
self.write_space(dst, level);
|
||||||
let name = name.replace(['-', '.'], "_");
|
let name = name.replace(['-', '.'], "_");
|
||||||
writeln!(dst, "pub mod {name} {{")?;
|
writeln!(dst, "pub mod {name} {{")?;
|
||||||
@ -73,24 +168,33 @@ impl Level {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_inner(&self, dst: &mut String, level: usize) -> fmt::Result {
|
fn write_inner(&self, dst: &mut String, level: usize) -> Result {
|
||||||
for (name, nested) in &self.nested {
|
for (name, nested) in &self.nested {
|
||||||
nested.write_into(dst, name, level)?;
|
nested.write_into(dst, name, level)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.write_space(dst, level);
|
|
||||||
|
|
||||||
for file in &self.files {
|
for file in &self.files {
|
||||||
let stem = Path::new(file)
|
let stem = file
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.unwrap()
|
.ok_or_else(|| format!("no filename in path {file:?}"))?
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap()
|
.ok_or_else(|| format!("non-UTF8 path: {file:?}"))?
|
||||||
.replace('-', "_");
|
.replace('-', "_");
|
||||||
|
|
||||||
self.write_space(dst, level);
|
if should_combine_code_blocks(file)? {
|
||||||
|
let res = combined_code_blocks(file)?;
|
||||||
writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?;
|
self.write_space(dst, level);
|
||||||
|
writeln!(dst, "/// ```rust, no_run")?;
|
||||||
|
for line in res.lines() {
|
||||||
|
self.write_space(dst, level);
|
||||||
|
writeln!(dst, "/// {line}")?;
|
||||||
|
}
|
||||||
|
self.write_space(dst, level);
|
||||||
|
writeln!(dst, "/// ```")?;
|
||||||
|
} else {
|
||||||
|
self.write_space(dst, level);
|
||||||
|
writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?;
|
||||||
|
}
|
||||||
self.write_space(dst, level);
|
self.write_space(dst, level);
|
||||||
writeln!(dst, "pub fn {stem}_md() {{}}")?;
|
writeln!(dst, "pub fn {stem}_md() {{}}")?;
|
||||||
}
|
}
|
||||||
@ -104,3 +208,48 @@ impl Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inner_main() -> Result {
|
||||||
|
let home = env::var("CARGO_MANIFEST_DIR")?;
|
||||||
|
let pattern = format!("{home}/../../website/docs/**/*.md*");
|
||||||
|
let base = format!("{home}/../../website");
|
||||||
|
let base = Path::new(&base).canonicalize()?;
|
||||||
|
let dir_pattern = format!("{home}/../../website/docs/**");
|
||||||
|
for dir in glob(&dir_pattern)? {
|
||||||
|
println!("cargo:rerun-if-changed={}", dir?.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut level = Level::default();
|
||||||
|
|
||||||
|
for entry in glob(&pattern)? {
|
||||||
|
let path = entry?.canonicalize()?;
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
let rel = path.strip_prefix(&base)?;
|
||||||
|
|
||||||
|
let mut parts = vec![];
|
||||||
|
|
||||||
|
for part in rel {
|
||||||
|
parts.push(
|
||||||
|
part.to_str()
|
||||||
|
.ok_or_else(|| format!("Non-UTF8 path: {rel:?}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
level.insert(path.clone(), &parts[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = format!("{}/website_tests.rs", env::var("OUT_DIR")?);
|
||||||
|
|
||||||
|
fs::write(out, level.to_contents()?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match inner_main() {
|
||||||
|
Ok(_) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
#![allow(clippy::needless_doctest_main)]
|
|
||||||
pub mod tutorial;
|
|
||||||
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/website_tests.rs"));
|
include!(concat!(env!("OUT_DIR"), "/website_tests.rs"));
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct Video {
|
|
||||||
pub id: usize,
|
|
||||||
pub title: String,
|
|
||||||
pub speaker: String,
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
@ -24,6 +24,55 @@ Note this only builds for English locale unlike a production build.
|
|||||||
> Documentation is written in `mdx`, a superset of markdown empowered with jsx.
|
> Documentation is written in `mdx`, a superset of markdown empowered with jsx.
|
||||||
> JetBrains and VSCode both provide MDX plugins.
|
> JetBrains and VSCode both provide MDX plugins.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo make website-test
|
||||||
|
```
|
||||||
|
|
||||||
|
[`website-test`](../tools/website-test) is a tool to test all code blocks in the docs as Rust doctests.
|
||||||
|
It gathers the Rust code blocks automatically, but by default they're all tested separate. In case of a
|
||||||
|
walkthrough, it makes more sense to combine the changes described in the blocks & test the code as one.
|
||||||
|
For this end `website-test` scans all doc files for a special flag:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- COMBINE CODE BLOCKS -->
|
||||||
|
```
|
||||||
|
If a file ends with this specific comment (and an optional newline after it), all code blocks will be
|
||||||
|
sown together, with respect to the diff markers in them. For example:
|
||||||
|
|
||||||
|
```md
|
||||||
|
\`\`\`rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, World");
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`rust
|
||||||
|
fn main() {
|
||||||
|
- println!("Hello, World");
|
||||||
|
+ println!("Goodbye, World");
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
<!-- COMBINE CODE BLOCKS -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Will be tested as:
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Goodbye, World");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
The current implementation only uses the code before the diff or the code to remove as context,
|
||||||
|
so make sure there's enough of it. The test assembler will tell you if there isn't.
|
||||||
|
:::
|
||||||
|
|
||||||
|
While assembling the code blocks, the test assembler will put special meaning into a code
|
||||||
|
line `// ...`. This line tells the test assembler to disregard any previous context for applying a diff
|
||||||
|
|
||||||
## Production Build
|
## Production Build
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
|||||||
@ -318,14 +318,12 @@ Props are evaluated in the order they're specified, as shown by the following ex
|
|||||||
#[derive(yew::Properties, PartialEq)]
|
#[derive(yew::Properties, PartialEq)]
|
||||||
struct Props { first: usize, second: usize, last: usize }
|
struct Props { first: usize, second: usize, last: usize }
|
||||||
|
|
||||||
fn main() {
|
let mut g = 1..=3;
|
||||||
let mut g = 1..=3;
|
let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() });
|
||||||
let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() });
|
|
||||||
|
|
||||||
assert_eq!(props.first, 1);
|
assert_eq!(props.first, 1);
|
||||||
assert_eq!(props.second, 2);
|
assert_eq!(props.second, 2);
|
||||||
assert_eq!(props.last, 3);
|
assert_eq!(props.last, 3);
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Anti Patterns
|
## Anti Patterns
|
||||||
|
|||||||
@ -164,7 +164,7 @@ html! { <div attribute={value} /> };
|
|||||||
|
|
||||||
Properties are specified with `~` before the element name:
|
Properties are specified with `~` before the element name:
|
||||||
|
|
||||||
```rust
|
```rust , ignore
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
html! { <my-element ~property="abc" /> };
|
html! { <my-element ~property="abc" /> };
|
||||||
|
|||||||
@ -185,22 +185,28 @@ We want to build a layout that looks something like this in raw HTML:
|
|||||||
Now, let's convert this HTML into `html!`. Type (or copy/paste) the following snippet into the body of `app` function
|
Now, let's convert this HTML into `html!`. Type (or copy/paste) the following snippet into the body of `app` function
|
||||||
such that the value of `html!` is returned by the function
|
such that the value of `html!` is returned by the function
|
||||||
|
|
||||||
```rust ,ignore
|
```rust {3-21}
|
||||||
html! {
|
#[function_component(App)]
|
||||||
<>
|
fn app() -> Html {
|
||||||
<h1>{ "RustConf Explorer" }</h1>
|
- html! {
|
||||||
<div>
|
- <h1>{ "Hello World" }</h1>
|
||||||
<h3>{"Videos to watch"}</h3>
|
- }
|
||||||
<p>{ "John Doe: Building and breaking things" }</p>
|
+ html! {
|
||||||
<p>{ "Jane Smith: The development process" }</p>
|
+ <>
|
||||||
<p>{ "Matt Miller: The Web 7.0" }</p>
|
+ <h1>{ "RustConf Explorer" }</h1>
|
||||||
<p>{ "Tom Jerry: Mouseless development" }</p>
|
+ <div>
|
||||||
</div>
|
+ <h3>{ "Videos to watch" }</h3>
|
||||||
<div>
|
+ <p>{ "John Doe: Building and breaking things" }</p>
|
||||||
<h3>{ "John Doe: Building and breaking things" }</h3>
|
+ <p>{ "Jane Smith: The development process" }</p>
|
||||||
<img src="https://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
+ <p>{ "Matt Miller: The Web 7.0" }</p>
|
||||||
</div>
|
+ <p>{ "Tom Jerry: Mouseless development" }</p>
|
||||||
</>
|
+ </div>
|
||||||
|
+ <div>
|
||||||
|
+ <h3>{ "John Doe: Building and breaking things" }</h3>
|
||||||
|
+ <img src="https://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
||||||
|
+ </div>
|
||||||
|
+ </>
|
||||||
|
+ }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -215,6 +221,7 @@ Now, instead of hardcoding the list of videos in the HTML, let's define them as
|
|||||||
We create a simple `struct` (in `main.rs` or any file of our choice) that will hold our data.
|
We create a simple `struct` (in `main.rs` or any file of our choice) that will hold our data.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
struct Video {
|
struct Video {
|
||||||
id: usize,
|
id: usize,
|
||||||
title: String,
|
title: String,
|
||||||
@ -225,44 +232,49 @@ struct Video {
|
|||||||
|
|
||||||
Next, we will create instances of this struct in our `app` function and use those instead of hardcoding the data:
|
Next, we will create instances of this struct in our `app` function and use those instead of hardcoding the data:
|
||||||
|
|
||||||
```rust
|
```rust {3-29}
|
||||||
use website_test::tutorial::Video; // replace with your own path
|
#[function_component(App)]
|
||||||
|
fn app() -> Html {
|
||||||
let videos = vec![
|
+ let videos = vec![
|
||||||
Video {
|
+ Video {
|
||||||
id: 1,
|
+ id: 1,
|
||||||
title: "Building and breaking things".to_string(),
|
+ title: "Building and breaking things".to_string(),
|
||||||
speaker: "John Doe".to_string(),
|
+ speaker: "John Doe".to_string(),
|
||||||
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
+ url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
},
|
+ },
|
||||||
Video {
|
+ Video {
|
||||||
id: 2,
|
+ id: 2,
|
||||||
title: "The development process".to_string(),
|
+ title: "The development process".to_string(),
|
||||||
speaker: "Jane Smith".to_string(),
|
+ speaker: "Jane Smith".to_string(),
|
||||||
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
+ url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
},
|
+ },
|
||||||
Video {
|
+ Video {
|
||||||
id: 3,
|
+ id: 3,
|
||||||
title: "The Web 7.0".to_string(),
|
+ title: "The Web 7.0".to_string(),
|
||||||
speaker: "Matt Miller".to_string(),
|
+ speaker: "Matt Miller".to_string(),
|
||||||
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
+ url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
},
|
+ },
|
||||||
Video {
|
+ Video {
|
||||||
id: 4,
|
+ id: 4,
|
||||||
title: "Mouseless development".to_string(),
|
+ title: "Mouseless development".to_string(),
|
||||||
speaker: "Tom Jerry".to_string(),
|
+ speaker: "Tom Jerry".to_string(),
|
||||||
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
+ url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
},
|
+ },
|
||||||
];
|
+ ];
|
||||||
|
+
|
||||||
```
|
```
|
||||||
|
|
||||||
To display them, we need to convert the `Vec` into `Html`. We can do that by creating an iterator,
|
To display them, we need to convert the `Vec` into `Html`. We can do that by creating an iterator,
|
||||||
mapping it to `html!` and collecting it as `Html`:
|
mapping it to `html!` and collecting it as `Html`:
|
||||||
|
|
||||||
```rust ,ignore
|
```rust {4-7}
|
||||||
let videos = videos.iter().map(|video| html! {
|
},
|
||||||
<p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
];
|
||||||
}).collect::<Html>();
|
|
||||||
|
+ let videos = videos.iter().map(|video| html! {
|
||||||
|
+ <p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||||||
|
+ }).collect::<Html>();
|
||||||
|
+
|
||||||
```
|
```
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
@ -271,21 +283,21 @@ Keys on list items help Yew keep track of which items have changed in the list,
|
|||||||
|
|
||||||
And finally, we need to replace the hardcoded list of videos with the `Html` we created from the data:
|
And finally, we need to replace the hardcoded list of videos with the `Html` we created from the data:
|
||||||
|
|
||||||
```rust ,ignore {6-10}
|
```rust {6-10}
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h1>{ "RustConf Explorer" }</h1>
|
<h1>{ "RustConf Explorer" }</h1>
|
||||||
<div>
|
<div>
|
||||||
<h3>{ "Videos to watch" }</h3>
|
<h3>{ "Videos to watch" }</h3>
|
||||||
- <p>{ "John Doe: Building and breaking things" }</p>
|
- <p>{ "John Doe: Building and breaking things" }</p>
|
||||||
- <p>{ "Jane Smith: The development process" }</p>
|
- <p>{ "Jane Smith: The development process" }</p>
|
||||||
- <p>{ "Matt Miller: The Web 7.0" }</p>
|
- <p>{ "Matt Miller: The Web 7.0" }</p>
|
||||||
- <p>{ "Tom Jerry: Mouseless development" }</p>
|
- <p>{ "Tom Jerry: Mouseless development" }</p>
|
||||||
+ { videos }
|
+ { videos }
|
||||||
</div>
|
</div>
|
||||||
// ...
|
// ...
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
@ -305,16 +317,7 @@ In this tutorial, we will be using function components.
|
|||||||
Now, let's split up our `App` component into smaller components. We begin by extracting the videos list into
|
Now, let's split up our `App` component into smaller components. We begin by extracting the videos list into
|
||||||
its own component.
|
its own component.
|
||||||
|
|
||||||
```rust ,compile_fail
|
```rust
|
||||||
use yew::prelude::*;
|
|
||||||
|
|
||||||
struct Video {
|
|
||||||
id: usize,
|
|
||||||
title: String,
|
|
||||||
speaker: String,
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct VideosListProps {
|
struct VideosListProps {
|
||||||
videos: Vec<Video>,
|
videos: Vec<Video>,
|
||||||
@ -336,33 +339,21 @@ In this case, `VideosListProps` is a struct that defines the props.
|
|||||||
The struct used for props must implement `Properties` by deriving it.
|
The struct used for props must implement `Properties` by deriving it.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
For the above code to compile, we need to modify the `Video` struct like this:
|
|
||||||
|
|
||||||
```rust {1}
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
struct Video {
|
|
||||||
id: usize,
|
|
||||||
title: String,
|
|
||||||
speaker: String,
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, we can update our `App` component to make use of `VideosList` component.
|
Now, we can update our `App` component to make use of `VideosList` component.
|
||||||
|
|
||||||
```rust ,ignore {4-7,13-14}
|
```rust {4-7,13-14}
|
||||||
#[function_component(App)]
|
#[function_component(App)]
|
||||||
fn app() -> Html {
|
fn app() -> Html {
|
||||||
// ...
|
// ...
|
||||||
- let videos = videos.iter().map(|video| html! {
|
- let videos = videos.iter().map(|video| html! {
|
||||||
- <p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
- <p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||||||
- }).collect::<Html>();
|
- }).collect::<Html>();
|
||||||
-
|
-
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h1>{ "RustConf Explorer" }</h1>
|
<h1>{ "RustConf Explorer" }</h1>
|
||||||
<div>
|
<div>
|
||||||
<h3>{"Videos to watch"}</h3>
|
<h3>{ "Videos to watch" }</h3>
|
||||||
- { videos }
|
- { videos }
|
||||||
+ <VideosList videos={videos} />
|
+ <VideosList videos={videos} />
|
||||||
</div>
|
</div>
|
||||||
@ -382,34 +373,35 @@ The final goal here is to display the selected video. To do that, `VideosList` c
|
|||||||
parent when a video is selected, which is done via a `Callback`. This concept is called "passing handlers".
|
parent when a video is selected, which is done via a `Callback`. This concept is called "passing handlers".
|
||||||
We modify its props to take an `on_click` callback:
|
We modify its props to take an `on_click` callback:
|
||||||
|
|
||||||
```rust ,ignore {4}
|
```rust {4}
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct VideosListProps {
|
struct VideosListProps {
|
||||||
videos: Vec<Video>,
|
videos: Vec<Video>,
|
||||||
+ on_click: Callback<Video>
|
+ on_click: Callback<Video>,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then we modify the `VideosList` component to "emit" the selected video to the callback.
|
Then we modify the `VideosList` component to "emit" the selected video to the callback.
|
||||||
|
|
||||||
```rust ,ignore {2-4,6-12,15-16}
|
```rust {2-18}
|
||||||
#[function_component(VideosList)]
|
#[function_component(VideosList)]
|
||||||
-fn videos_list(VideosListProps { videos }: &VideosListProps) -> Html {
|
-fn videos_list(VideosListProps { videos }: &VideosListProps) -> Html {
|
||||||
+fn videos_list(VideosListProps { videos, on_click }: &VideosListProps) -> Html {
|
+fn videos_list(VideosListProps { videos, on_click }: &VideosListProps) -> Html {
|
||||||
+ let on_click = on_click.clone();
|
- videos.iter().map(|video| html! {
|
||||||
videos.iter().map(|video| {
|
- <p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||||||
+ let on_video_select = {
|
+ let on_click = on_click.clone();
|
||||||
+ let on_click = on_click.clone();
|
+ videos.iter().map(|video| {
|
||||||
+ let video = video.clone();
|
+ let on_video_select = {
|
||||||
+ Callback::from(move |_| {
|
+ let on_click = on_click.clone();
|
||||||
+ on_click.emit(video.clone())
|
+ let video = video.clone();
|
||||||
+ })
|
+ Callback::from(move |_| {
|
||||||
+ };
|
+ on_click.emit(video.clone())
|
||||||
|
+ })
|
||||||
html! {
|
+ };
|
||||||
- <p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
+
|
||||||
|
+ html! {
|
||||||
+ <p key={video.id} onclick={on_video_select}>{format!("{}: {}", video.speaker, video.title)}</p>
|
+ <p key={video.id} onclick={on_video_select}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||||||
}
|
+ }
|
||||||
}).collect()
|
}).collect()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -418,9 +410,6 @@ Next, we need to modify the usage of `VideosList` to pass that callback. But bef
|
|||||||
a new component, `VideoDetails`, that is displayed when a video is clicked.
|
a new component, `VideoDetails`, that is displayed when a video is clicked.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use website_test::tutorial::Video;
|
|
||||||
use yew::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
struct VideosDetailsProps {
|
struct VideosDetailsProps {
|
||||||
video: Video,
|
video: Video,
|
||||||
@ -439,36 +428,36 @@ fn video_details(VideosDetailsProps { video }: &VideosDetailsProps) -> Html {
|
|||||||
|
|
||||||
Now, modify the `App` component to display `VideoDetails` component whenever a video is selected.
|
Now, modify the `App` component to display `VideoDetails` component whenever a video is selected.
|
||||||
|
|
||||||
```rust ,ignore {4,6-11,13-15,22-23,25-29}
|
```rust {3-15,22-23,25-29}
|
||||||
#[function_component(App)]
|
},
|
||||||
fn app() -> Html {
|
];
|
||||||
// ...
|
+
|
||||||
+ let selected_video = use_state(|| None);
|
+ let selected_video = use_state(|| None);
|
||||||
|
+
|
||||||
+ let on_video_select = {
|
+ let on_video_select = {
|
||||||
+ let selected_video = selected_video.clone();
|
+ let selected_video = selected_video.clone();
|
||||||
+ Callback::from(move |video: Video| {
|
+ Callback::from(move |video: Video| {
|
||||||
+ selected_video.set(Some(video))
|
+ selected_video.set(Some(video))
|
||||||
+ })
|
+ })
|
||||||
+ };
|
+ };
|
||||||
|
+
|
||||||
+ let details = selected_video.as_ref().map(|video| html! {
|
+ let details = selected_video.as_ref().map(|video| html! {
|
||||||
+ <VideoDetails video={video.clone()} />
|
+ <VideoDetails video={video.clone()} />
|
||||||
+ });
|
+ });
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h1>{ "RustConf Explorer" }</h1>
|
<h1>{ "RustConf Explorer" }</h1>
|
||||||
<div>
|
<div>
|
||||||
<h3>{"Videos to watch"}</h3>
|
<h3>{ "Videos to watch" }</h3>
|
||||||
- <VideosList videos={videos} />
|
- <VideosList videos={videos} />
|
||||||
+ <VideosList videos={videos} on_click={on_video_select.clone()} />
|
+ <VideosList videos={videos} on_click={on_video_select.clone()} />
|
||||||
</div>
|
</div>
|
||||||
+ { for details }
|
+ { for details }
|
||||||
- <div>
|
- <div>
|
||||||
- <h3>{ "John Doe: Building and breaking things" }</h3>
|
- <h3>{ "John Doe: Building and breaking things" }</h3>
|
||||||
- <img src="https://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
- <img src="https://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
||||||
- </div>
|
- </div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -517,11 +506,12 @@ Otherwise you won't be able to run your application.
|
|||||||
|
|
||||||
Update the `Video` struct to derive the `Deserialize` trait:
|
Update the `Video` struct to derive the `Deserialize` trait:
|
||||||
|
|
||||||
```rust ,ignore {1, 3-4}
|
```rust {2,4-5}
|
||||||
+ use serde::Deserialize;
|
use yew::prelude::*;
|
||||||
|
+use serde::Deserialize;
|
||||||
- #[derive(Clone, PartialEq)]
|
// ...
|
||||||
+ #[derive(Clone, PartialEq, Deserialize)]
|
-#[derive(Clone, PartialEq)]
|
||||||
|
+#[derive(Clone, PartialEq, Deserialize)]
|
||||||
struct Video {
|
struct Video {
|
||||||
id: usize,
|
id: usize,
|
||||||
title: String,
|
title: String,
|
||||||
@ -532,32 +522,57 @@ struct Video {
|
|||||||
|
|
||||||
Now as the last step, we need to update our `App` component to make the fetch request instead of using hardcoded data
|
Now as the last step, we need to update our `App` component to make the fetch request instead of using hardcoded data
|
||||||
|
|
||||||
```rust ,ignore {1,5-25,34-35}
|
```rust {2,6-50,59-60}
|
||||||
+ use gloo_net::http::Request;
|
use yew::prelude::*;
|
||||||
|
+use gloo_net::http::Request;
|
||||||
|
|
||||||
#[function_component(App)]
|
#[function_component(App)]
|
||||||
fn app() -> Html {
|
fn app() -> Html {
|
||||||
- let videos = vec![
|
- let videos = vec![
|
||||||
- // ...
|
- Video {
|
||||||
- ]
|
- id: 1,
|
||||||
+ let videos = use_state(|| vec![]);
|
- title: "Building and breaking things".to_string(),
|
||||||
+ {
|
- speaker: "John Doe".to_string(),
|
||||||
+ let videos = videos.clone();
|
- url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
+ use_effect_with((), move |_| {
|
- },
|
||||||
+ let videos = videos.clone();
|
- Video {
|
||||||
+ wasm_bindgen_futures::spawn_local(async move {
|
- id: 2,
|
||||||
+ let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
|
- title: "The development process".to_string(),
|
||||||
+ .send()
|
- speaker: "Jane Smith".to_string(),
|
||||||
+ .await
|
- url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
+ .unwrap()
|
- },
|
||||||
+ .json()
|
- Video {
|
||||||
+ .await
|
- id: 3,
|
||||||
+ .unwrap();
|
- title: "The Web 7.0".to_string(),
|
||||||
+ videos.set(fetched_videos);
|
- speaker: "Matt Miller".to_string(),
|
||||||
+ });
|
- url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
+ || ()
|
- },
|
||||||
+ });
|
- Video {
|
||||||
+ }
|
- id: 4,
|
||||||
|
- title: "Mouseless development".to_string(),
|
||||||
|
- speaker: "Tom Jerry".to_string(),
|
||||||
|
- url: "https://youtu.be/PsaFVLr8t4E".to_string(),
|
||||||
|
- },
|
||||||
|
- ];
|
||||||
|
-
|
||||||
|
+ let videos = use_state(|| vec![]);
|
||||||
|
+ {
|
||||||
|
+ let videos = videos.clone();
|
||||||
|
+ use_effect_with((), move |_| {
|
||||||
|
+ let videos = videos.clone();
|
||||||
|
+ wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
+ let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
|
||||||
|
+ .send()
|
||||||
|
+ .await
|
||||||
|
+ .unwrap()
|
||||||
|
+ .json()
|
||||||
|
+ .await
|
||||||
|
+ .unwrap();
|
||||||
|
+ videos.set(fetched_videos);
|
||||||
|
+ });
|
||||||
|
+ || ()
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
@ -565,9 +580,9 @@ fn app() -> Html {
|
|||||||
<>
|
<>
|
||||||
<h1>{ "RustConf Explorer" }</h1>
|
<h1>{ "RustConf Explorer" }</h1>
|
||||||
<div>
|
<div>
|
||||||
<h3>{"Videos to watch"}</h3>
|
<h3>{ "Videos to watch" }</h3>
|
||||||
- <VideosList videos={videos} on_click={on_video_select.clone()} />
|
- <VideosList videos={videos} on_click={on_video_select.clone()} />
|
||||||
+ <VideosList videos={(*videos).clone()} on_click={on_video_select.clone()} />
|
+ <VideosList videos={(*videos).clone()} on_click={on_video_select.clone()} />
|
||||||
</div>
|
</div>
|
||||||
{ for details }
|
{ for details }
|
||||||
</>
|
</>
|
||||||
@ -585,11 +600,9 @@ To fix that, we need a proxy server. Luckily trunk provides that.
|
|||||||
|
|
||||||
Update the following line:
|
Update the following line:
|
||||||
|
|
||||||
```rust ,ignore {2-3}
|
```rust {2-3}
|
||||||
// ...
|
- let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
|
||||||
- let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
|
+ let fetched_videos: Vec<Video> = Request::get("/tutorial/data.json")
|
||||||
+ let fetched_videos: Vec<Video> = Request::get("/tutorial/data.json")
|
|
||||||
// ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, rerun the server with the following command:
|
Now, rerun the server with the following command:
|
||||||
@ -624,3 +637,5 @@ See [external libraries](/community/external-libs) for more details.
|
|||||||
|
|
||||||
Read our [official documentation](../getting-started/introduction.mdx). It explains a lot of concepts in much more detail.
|
Read our [official documentation](../getting-started/introduction.mdx). It explains a lot of concepts in much more detail.
|
||||||
To learn more about the Yew API, see our [API docs](https://docs.rs/yew).
|
To learn more about the Yew API, see our [API docs](https://docs.rs/yew).
|
||||||
|
|
||||||
|
<!-- COMBINE CODE BLOCKS -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user