mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 11:21:55 -05:00
Compare commits
38 Commits
remove-mut
...
fix-compon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e01bf989 | ||
|
|
49820ccba6 | ||
|
|
b9ca0b11a2 | ||
|
|
296e27cd4a | ||
|
|
fd3443b129 | ||
|
|
aa3dd356c1 | ||
|
|
35ca30fbab | ||
|
|
132f0839c6 | ||
|
|
e9c1799a11 | ||
|
|
4577313cca | ||
|
|
f75d49fe4c | ||
|
|
6a38375c66 | ||
|
|
f9f4fb0fef | ||
|
|
42cd3f1d69 | ||
|
|
ade2eda26d | ||
|
|
680b6ecc20 | ||
|
|
4e9d0354c6 | ||
|
|
cccd7068f9 | ||
|
|
8f56a52615 | ||
|
|
6c04e91088 | ||
|
|
9ef350c2d6 | ||
|
|
f559d47714 | ||
|
|
2483616d0d | ||
|
|
2595ffe10e | ||
|
|
221cdf2685 | ||
|
|
1cb278f520 | ||
|
|
5cfd44474d | ||
|
|
bd652ec542 | ||
|
|
d8852f909e | ||
|
|
e16cc4fc4a | ||
|
|
d5e3661bcf | ||
|
|
8873ddc40a | ||
|
|
b7e2e983f0 | ||
|
|
3701f65693 | ||
|
|
a5712d3e17 | ||
|
|
4fba035f19 | ||
|
|
47fad9a042 | ||
|
|
c8545f47cb |
51
.github/workflows/test.yml
vendored
Normal file
51
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on ${{ matrix.os }} (using rustc ${{ matrix.rust }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- nightly
|
||||
os:
|
||||
- ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
|
||||
- name: Setup cargo-make
|
||||
uses: davidB/rust-cargo-make@v1
|
||||
|
||||
- name: Cargo generate-lockfile
|
||||
run: cargo generate-lockfile
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run tests with all features
|
||||
run: cargo make ci
|
||||
|
||||
@@ -26,8 +26,6 @@ members = [
|
||||
"examples/parent-child",
|
||||
"examples/router",
|
||||
"examples/todomvc",
|
||||
"examples/todomvc-ssr/todomvc-ssr-client",
|
||||
"examples/todomvc-ssr/todomvc-ssr-server",
|
||||
]
|
||||
exclude = [
|
||||
"benchmarks",
|
||||
|
||||
@@ -5,6 +5,7 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<MyComponent><p>"Here's the child"</p></MyComponent>
|
||||
<button on:click=move |_| set_value(0)>"Clear"</button>
|
||||
<button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button>
|
||||
<span>"Value: " {move || value().to_string()} "!"</span>
|
||||
@@ -12,3 +13,14 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MyComponent(cx: Scope, children: Option<Box<dyn Fn() -> Vec<Element>>>) -> Element {
|
||||
view! {
|
||||
cx,
|
||||
<my-component>
|
||||
<p>"Here's the child you passed in: "</p>
|
||||
<slot></slot>
|
||||
</my-component>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,46 +31,26 @@ async fn render_app(req: HttpRequest) -> impl Responder {
|
||||
view! { cx, <App/> }
|
||||
};
|
||||
|
||||
let accepts_type = req.headers().get("Accept").map(|h| h.to_str());
|
||||
match accepts_type {
|
||||
// if asks for JSON, send the loader function JSON or 404
|
||||
Some(Ok("application/json")) => {
|
||||
let json = loader_to_json(app).await;
|
||||
let head = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
|
||||
let tail = "</body></html>";
|
||||
|
||||
let res = if let Some(json) = json {
|
||||
HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(json)
|
||||
} else {
|
||||
HttpResponse::NotFound().body(())
|
||||
};
|
||||
|
||||
res
|
||||
}
|
||||
// otherwise, send HTML
|
||||
_ => {
|
||||
let head = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
|
||||
let tail = "</body></html>";
|
||||
|
||||
HttpResponse::Ok().content_type("text/html").streaming(
|
||||
futures::stream::once(async { head.to_string() })
|
||||
.chain(render_to_stream(move |cx| {
|
||||
let app = app(cx);
|
||||
let head = use_context::<MetaContext>(cx)
|
||||
.map(|meta| meta.dehydrate())
|
||||
.unwrap_or_default();
|
||||
format!("{head}</head><body>{app}")
|
||||
}))
|
||||
.chain(futures::stream::once(async { tail.to_string() }))
|
||||
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
|
||||
)
|
||||
}
|
||||
}
|
||||
HttpResponse::Ok().content_type("text/html").streaming(
|
||||
futures::stream::once(async { head.to_string() })
|
||||
.chain(render_to_stream(move |cx| {
|
||||
let app = app(cx);
|
||||
let head = use_context::<MetaContext>(cx)
|
||||
.map(|meta| meta.dehydrate())
|
||||
.unwrap_or_default();
|
||||
format!("{head}</head><body>{app}")
|
||||
}))
|
||||
.chain(futures::stream::once(async { tail.to_string() }))
|
||||
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
|
||||
)
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "todomvc-ssr-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_log = "0.2"
|
||||
leptos = { path = "../../../leptos", default-features = false, features = ["hydrate"] }
|
||||
todomvc = { path = "../../todomvc", default-features = false, features = ["hydrate"] }
|
||||
log = "0.4"
|
||||
wasm-bindgen = "0.2"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
wasm-pack build --target=web --release
|
||||
@@ -1,22 +0,0 @@
|
||||
use leptos::*;
|
||||
use todomvc::*;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
log::debug!("initialized logging");
|
||||
|
||||
leptos::hydrate(body().unwrap(), |cx| {
|
||||
// initial state — identical to server
|
||||
let todos = Todos(vec![
|
||||
Todo::new(cx, 0, "Buy milk".to_string()),
|
||||
Todo::new(cx, 1, "???".to_string()),
|
||||
Todo::new(cx, 2, "Profit!".to_string()),
|
||||
]);
|
||||
|
||||
view! { cx, <TodoMVC todos=todos/> }
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "todomvc-ssr-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-files = "0.6"
|
||||
actix-web = "4"
|
||||
leptos = { path = "../../../leptos", default-features = false, features = ["ssr"] }
|
||||
todomvc = { path = "../../todomvc", default-features = false, features = ["ssr"] }
|
||||
@@ -1,51 +0,0 @@
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
use todomvc::*;
|
||||
|
||||
#[get("/")]
|
||||
async fn render_todomvc() -> impl Responder {
|
||||
HttpResponse::Ok().content_type("text/html").body(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="stylesheet" href="/static/todomvc-common/base.css"/>
|
||||
<link rel="stylesheet" href="/static/todomvc-app-css/index.css"/>
|
||||
<title>"Leptos • TodoMVC"</title>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
</body>
|
||||
<script type="module">import init, {{ main }} from './pkg/todomvc_ssr_client.js'; init().then(main);</script>
|
||||
</html>"#,
|
||||
run_scope({
|
||||
|cx| {
|
||||
let todos = Todos(vec![
|
||||
Todo::new(cx, 0, "Buy milk".to_string()),
|
||||
Todo::new(cx, 1, "???".to_string()),
|
||||
Todo::new(cx, 2, "Profit!".to_string()),
|
||||
]);
|
||||
|
||||
view! { cx,
|
||||
<TodoMVC todos=todos/>
|
||||
}
|
||||
}
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
HttpServer::new(|| {
|
||||
App::new()
|
||||
.service(render_todomvc)
|
||||
.service(Files::new("/static", "../../todomvc/node_modules"))
|
||||
.service(Files::new("/pkg", "../todomvc-ssr-client/pkg"))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
@@ -5,15 +5,16 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos", default-features = false }
|
||||
miniserde = "0.1"
|
||||
log = "0.4"
|
||||
console_log = "0.2"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
uuid = { version = "1", features = ["v4", "js", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use leptos::{web_sys::HtmlInputElement, *};
|
||||
use miniserde::json;
|
||||
use storage::TodoSerialized;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod storage;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct Todos(pub Vec<Todo>);
|
||||
|
||||
const STORAGE_KEY: &str = "todos-leptos";
|
||||
|
||||
// Basic operations to manipulate the todo list: nothing really interesting here
|
||||
impl Todos {
|
||||
pub fn new(cx: Scope) -> Self {
|
||||
let starting_todos = if is_server!() {
|
||||
@@ -18,7 +19,7 @@ impl Todos {
|
||||
.get_item(STORAGE_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| json::from_str::<Vec<TodoSerialized>>(&value).ok())
|
||||
.and_then(|value| serde_json::from_str::<Vec<TodoSerialized>>(&value).ok())
|
||||
.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
@@ -40,31 +41,33 @@ impl Todos {
|
||||
self.0.push(todo);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: usize) {
|
||||
pub fn remove(&mut self, id: Uuid) {
|
||||
self.0.retain(|todo| todo.id != id);
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.0.iter().filter(|todo| !(todo.completed)()).count()
|
||||
// `todo.completed` is a signal, so we call .get() to access its value
|
||||
self.0.iter().filter(|todo| !todo.completed.get()).count()
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> usize {
|
||||
self.0.iter().filter(|todo| (todo.completed)()).count()
|
||||
// `todo.completed` is a signal, so we call .get() to access its value
|
||||
self.0.iter().filter(|todo| todo.completed.get()).count()
|
||||
}
|
||||
|
||||
pub fn toggle_all(&self) {
|
||||
// if all are complete, mark them all active instead
|
||||
// if all are complete, mark them all active
|
||||
if self.remaining() == 0 {
|
||||
for todo in &self.0 {
|
||||
if todo.completed.get() {
|
||||
(todo.set_completed)(false);
|
||||
todo.completed.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// otherwise, mark them all complete
|
||||
else {
|
||||
for todo in &self.0 {
|
||||
(todo.set_completed)(true);
|
||||
todo.completed.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,33 +79,35 @@ impl Todos {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Todo {
|
||||
pub id: usize,
|
||||
pub title: ReadSignal<String>,
|
||||
pub set_title: WriteSignal<String>,
|
||||
pub completed: ReadSignal<bool>,
|
||||
pub set_completed: WriteSignal<bool>,
|
||||
pub id: Uuid,
|
||||
pub title: RwSignal<String>,
|
||||
pub completed: RwSignal<bool>,
|
||||
}
|
||||
|
||||
impl Todo {
|
||||
pub fn new(cx: Scope, id: usize, title: String) -> Self {
|
||||
pub fn new(cx: Scope, id: Uuid, title: String) -> Self {
|
||||
Self::new_with_completed(cx, id, title, false)
|
||||
}
|
||||
|
||||
pub fn new_with_completed(cx: Scope, id: usize, title: String, completed: bool) -> Self {
|
||||
let (title, set_title) = create_signal(cx, title);
|
||||
let (completed, set_completed) = create_signal(cx, completed);
|
||||
pub fn new_with_completed(cx: Scope, id: Uuid, title: String, completed: bool) -> Self {
|
||||
// RwSignal combines the getter and setter in one struct, rather than separating
|
||||
// the getter from the setter. This makes it more convenient in some cases, such
|
||||
// as when we're putting the signals into a struct and passing it around. There's
|
||||
// no real difference: you could use `create_signal` here, or use `create_rw_signal`
|
||||
// everywhere.
|
||||
let title = create_rw_signal(cx, title);
|
||||
let completed = create_rw_signal(cx, completed);
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
set_title,
|
||||
completed,
|
||||
set_completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self) {
|
||||
self.set_completed
|
||||
.update(|completed| *completed = !*completed);
|
||||
// A signal's `update()` function gives you a mutable reference to the current value
|
||||
// You can use that to modify the value in place, which will notify any subscribers.
|
||||
self.completed.update(|completed| *completed = !*completed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,24 +115,25 @@ const ESCAPE_KEY: u32 = 27;
|
||||
const ENTER_KEY: u32 = 13;
|
||||
|
||||
#[component]
|
||||
pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
let mut next_id = todos
|
||||
.0
|
||||
.iter()
|
||||
.map(|todo| todo.id)
|
||||
.max()
|
||||
.map(|last| last + 1)
|
||||
.unwrap_or(0);
|
||||
pub fn TodoMVC(cx: Scope) -> Element {
|
||||
// The `todos` are a signal, since we need to reactively update the list
|
||||
let (todos, set_todos) = create_signal(cx, Todos::new(cx));
|
||||
|
||||
let (todos, set_todos) = create_signal(cx, todos);
|
||||
// We provide a context that each <Todo/> component can use to update the list
|
||||
// Here, I'm just passing the `WriteSignal`; a <Todo/> doesn't need to read the whole list
|
||||
// (and shouldn't try to, as that would cause each individual <Todo/> to re-render when
|
||||
// a new todo is added! This kind of hygiene is why `create_signal` defaults to read-write
|
||||
// segregation.)
|
||||
provide_context(cx, set_todos);
|
||||
|
||||
// Handle the three filter modes: All, Active, and Completed
|
||||
let (mode, set_mode) = create_signal(cx, Mode::All);
|
||||
window_event_listener("hashchange", move |_| {
|
||||
let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default();
|
||||
set_mode(new_mode);
|
||||
});
|
||||
|
||||
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
|
||||
let add_todo = move |ev: web_sys::Event| {
|
||||
let target = event_target::<HtmlInputElement>(&ev);
|
||||
ev.stop_propagation();
|
||||
@@ -136,15 +142,16 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
let title = event_target_value(&ev);
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
let new = Todo::new(cx, next_id, title.to_string());
|
||||
let new = Todo::new(cx, Uuid::new_v4(), title.to_string());
|
||||
set_todos.update(|t| t.add(new));
|
||||
next_id += 1;
|
||||
target.set_value("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let filtered_todos = create_memo::<Vec<Todo>>(cx, move |_| {
|
||||
// A derived signal that filters the list of the todos depending on the filter mode
|
||||
// This doesn't need to be a `Memo`, because we're only reading it in one place
|
||||
let filtered_todos = move || {
|
||||
todos.with(|todos| match mode.get() {
|
||||
Mode::All => todos.0.to_vec(),
|
||||
Mode::Active => todos
|
||||
@@ -160,10 +167,15 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
// effect to serialize to JSON
|
||||
// this does reactive reads, so it will automatically serialize on any relevant change
|
||||
// Serialization
|
||||
//
|
||||
// the effect reads the `todos` signal, and each `Todo`'s title and completed
|
||||
// status, so it will automatically re-run on any change to the list of tasks
|
||||
//
|
||||
// this is the main point of `create_effect`: to synchronize reactive state
|
||||
// with something outside the reactive system (like localStorage)
|
||||
create_effect(cx, move |_| {
|
||||
if let Ok(Some(storage)) = window().local_storage() {
|
||||
let objs = todos
|
||||
@@ -172,7 +184,7 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
.iter()
|
||||
.map(TodoSerialized::from)
|
||||
.collect::<Vec<_>>();
|
||||
let json = json::to_string(&objs);
|
||||
let json = serde_json::to_string(&objs).expect("couldn't serialize Todos");
|
||||
if storage.set_item(STORAGE_KEY, &json).is_err() {
|
||||
log::error!("error while trying to set item in localStorage");
|
||||
}
|
||||
@@ -184,9 +196,17 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
<section class="todoapp">
|
||||
<header class="header">
|
||||
<h1>"todos"</h1>
|
||||
<input class="new-todo" placeholder="What needs to be done?" autofocus on:keydown=add_todo />
|
||||
<input
|
||||
class="new-todo"
|
||||
placeholder="What needs to be done?"
|
||||
autofocus
|
||||
on:keydown=add_todo
|
||||
/>
|
||||
</header>
|
||||
<section class="main" class:hidden={move || todos.with(|t| t.is_empty())}>
|
||||
<section
|
||||
class="main"
|
||||
class:hidden={move || todos.with(|t| t.is_empty())}
|
||||
>
|
||||
<input id="toggle-all" class="toggle-all" type="checkbox"
|
||||
prop:checked={move || todos.with(|t| t.remaining() > 0)}
|
||||
on:input=move |_| set_todos.update(|t| t.toggle_all())
|
||||
@@ -198,7 +218,10 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
<footer class="footer" class:hidden={move || todos.with(|t| t.is_empty())}>
|
||||
<footer
|
||||
class="footer"
|
||||
class:hidden={move || todos.with(|t| t.is_empty())}
|
||||
>
|
||||
<span class="todo-count">
|
||||
<strong>{move || todos.with(|t| t.remaining().to_string())}</strong>
|
||||
{move || if todos.with(|t| t.remaining()) == 1 {
|
||||
@@ -235,6 +258,8 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
let (editing, set_editing) = create_signal(cx, false);
|
||||
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
|
||||
|
||||
// this will be filled by _ref=input below
|
||||
let input: Element;
|
||||
|
||||
let save = move |value: &str| {
|
||||
@@ -242,16 +267,19 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
if value.is_empty() {
|
||||
set_todos.update(|t| t.remove(todo.id));
|
||||
} else {
|
||||
(todo.set_title)(value.to_string());
|
||||
todo.title.set(value.to_string());
|
||||
}
|
||||
set_editing(false);
|
||||
};
|
||||
|
||||
// the `input` variable above is filled by a ref, when the template is created
|
||||
// so we create the template and store it in a variable here, so we can
|
||||
// set up an effect using the `input` below
|
||||
let tpl = view! { cx,
|
||||
<li
|
||||
class="todo"
|
||||
class:editing={editing}
|
||||
class:completed={move || (todo.completed)()}
|
||||
class:completed={move || todo.completed.get()}
|
||||
_ref=input
|
||||
>
|
||||
<div class="view">
|
||||
@@ -261,7 +289,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
prop:checked={move || (todo.completed)()}
|
||||
on:input={move |ev| {
|
||||
let checked = event_target_checked(&ev);
|
||||
(todo.set_completed)(checked);
|
||||
todo.completed.set(checked);
|
||||
}}
|
||||
/>
|
||||
<label on:dblclick=move |_| set_editing(true)>
|
||||
@@ -289,10 +317,13 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
</li>
|
||||
};
|
||||
|
||||
// toggling to edit mode should focus the input
|
||||
#[cfg(any(feature = "csr", feature = "hydrate"))]
|
||||
create_effect(cx, move |_| {
|
||||
if editing() {
|
||||
_ = input.unchecked_ref::<HtmlInputElement>().focus();
|
||||
if let Some(input) = input.dyn_ref::<HtmlInputElement>() {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| view! { cx, <TodoMVC todos=Todos::new(cx)/> })
|
||||
mount_to_body(|cx| view! { cx, <TodoMVC/> })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::Todo;
|
||||
use leptos::Scope;
|
||||
use miniserde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TodoSerialized {
|
||||
pub id: usize,
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
@@ -20,7 +21,7 @@ impl From<&Todo> for TodoSerialized {
|
||||
Self {
|
||||
id: todo.id,
|
||||
title: todo.title.get(),
|
||||
completed: (todo.completed)(),
|
||||
completed: todo.completed.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos"
|
||||
version = "0.0.11"
|
||||
version = "0.0.15"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -9,11 +9,11 @@ description = "Leptos is a full-stack, isomorphic Rust web framework leveraging
|
||||
readme = "../README.md"
|
||||
|
||||
[dependencies]
|
||||
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.11" }
|
||||
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.13" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.13" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.15" }
|
||||
|
||||
[features]
|
||||
default = ["csr", "serde"]
|
||||
|
||||
@@ -53,10 +53,11 @@
|
||||
//!
|
||||
//! Here are links to the most important sections of the docs:
|
||||
//! - **Reactivity**: the [leptos_reactive] overview, and more details in
|
||||
//! - [create_signal], [ReadSignal], and [WriteSignal] (and [create_rw_signal] and [RwSignal])
|
||||
//! - [create_memo] and [Memo]
|
||||
//! - [create_resource] and [Resource]
|
||||
//! - [create_effect]
|
||||
//! - signals: [create_signal], [ReadSignal], and [WriteSignal] (and [create_rw_signal] and [RwSignal])
|
||||
//! - computations: [create_memo] and [Memo]
|
||||
//! - `async` interop: [create_resource] and [Resource] for loading data using `async` functions,
|
||||
//! and [create_action] and [Action] to mutate data or imperatively call `async` functions.
|
||||
//! - reactions: [create_effect]
|
||||
//! - **Templating/Views**: the [view] macro
|
||||
//! - **Routing**: the [leptos_router](https://docs.rs/leptos_router/latest/leptos_router/) crate
|
||||
//!
|
||||
|
||||
@@ -81,3 +81,33 @@ fn test_classes() {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
|
||||
#[test]
|
||||
fn test_dash_prefixes() {
|
||||
use leptos_dom::*;
|
||||
use leptos_macro::view;
|
||||
use leptos_reactive::{create_signal, run_scope};
|
||||
|
||||
let colons = run_scope(|cx| {
|
||||
let (value, set_value) = create_signal(cx, 5);
|
||||
view! {
|
||||
cx,
|
||||
<div class="my big" class:a={move || value() > 10} class:red=true class:car={move || value() > 1} attr:id="id"></div>
|
||||
}
|
||||
});
|
||||
|
||||
let dashes = run_scope(|cx| {
|
||||
let (value, set_value) = create_signal(cx, 5);
|
||||
view! {
|
||||
cx,
|
||||
<div class="my big" class-a={move || value() > 10} class-red=true class-car={move || value() > 1} attr-id="id"></div>
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(colons, dashes);
|
||||
assert_eq!(
|
||||
dashes,
|
||||
"<div data-hk=\"0-0\" class=\"my big red car\" id=\"id\"></div>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_core"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -8,9 +8,9 @@ repository = "https://github.com/gbj/leptos"
|
||||
description = "Core functionality for the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.13" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
log = "0.4"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_dom"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -12,7 +12,7 @@ cfg-if = "1"
|
||||
futures = "0.3"
|
||||
html-escape = "0.2"
|
||||
js-sys = "0.3"
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4.31"
|
||||
|
||||
@@ -75,17 +75,13 @@ pub fn insert_before(
|
||||
debug_warn!("insert_before: trying to insert on a parent node that is not an element");
|
||||
new.clone()
|
||||
} else if let Some(existing) = existing {
|
||||
if existing.parent_node().as_ref() == Some(parent.unchecked_ref()) {
|
||||
match parent.insert_before(new, Some(existing)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug_warn!("{:?}", e.as_string());
|
||||
new.clone()
|
||||
}
|
||||
let parent = existing.parent_node().unwrap_throw();
|
||||
match parent.insert_before(new, Some(existing)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug_warn!("{:?}", e.as_string());
|
||||
new.clone()
|
||||
}
|
||||
} else {
|
||||
debug_warn!("insert_before: existing node is not a child of parent node");
|
||||
parent.append_child(new).unwrap_throw()
|
||||
}
|
||||
} else {
|
||||
parent.append_child(new).unwrap_throw()
|
||||
|
||||
@@ -233,11 +233,7 @@ pub fn insert_expression(
|
||||
}
|
||||
Child::Null => match before {
|
||||
Marker::BeforeChild(before) => {
|
||||
if before.is_connected() {
|
||||
Child::Node(insert_before(&parent, node, Some(before)))
|
||||
} else {
|
||||
Child::Node(append_child(&parent, node))
|
||||
}
|
||||
Child::Node(insert_before(&parent, node, Some(before)))
|
||||
}
|
||||
_ => Child::Node(append_child(&parent, node)),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_macro"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -15,10 +15,10 @@ proc-macro-error = "1"
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "1", features = ["full", "parsing", "extra-traits"] }
|
||||
syn-rsx = "0.8.1"
|
||||
syn-rsx = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
leptos_dom = { path = "../leptos_dom", version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", version = "0.0.12" }
|
||||
leptos_reactive = { path = "../leptos_reactive", version = "0.0.12" }
|
||||
|
||||
[dev-dependencies]
|
||||
log = "0.4"
|
||||
|
||||
@@ -83,9 +83,13 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 4. Dynamic content can be wrapped in curly braces (`{ }`) to insert text nodes, elements, or set attributes.
|
||||
/// If you insert signal here, Leptos will create an effect to update the DOM whenever the value changes.
|
||||
/// If you insert a signal here, Leptos will create an effect to update the DOM whenever the value changes.
|
||||
/// *(“Signal” here means `Fn() -> T` where `T` is the appropriate type for that node: a `String` in case
|
||||
/// of text nodes, a `bool` for `class:` attributes, etc.)*
|
||||
///
|
||||
/// Attributes can take a wide variety of primitive types that can be converted to strings. They can also
|
||||
/// take an `Option`, in which case `Some` sets the attribute and `None` removes the attribute.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast; use leptos_dom as leptos; use leptos_dom::Marker;
|
||||
/// # run_scope(|cx| {
|
||||
@@ -105,7 +109,7 @@ mod server;
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// 5. Event handlers can be added with `on:` attributes
|
||||
/// 5. Event handlers can be added with `on:` attributes. If the event name contains a dash, you should use `on-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
@@ -124,7 +128,10 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 6. DOM properties can be set with `prop:` attributes, which take any primitive type or `JsValue` (or a signal
|
||||
/// that returns a primitive or JsValue).
|
||||
/// that returns a primitive or JsValue). They can also take an `Option`, in which case `Some` sets the property
|
||||
/// and `None` deletes the property.
|
||||
///
|
||||
/// If your property name contains a dash, you should use `prop-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
@@ -147,6 +154,7 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 7. Classes can be toggled with `class:` attributes, which take a `bool` (or a signal that returns a `bool`).
|
||||
/// If your class name contains a dash, you should use `class-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
|
||||
}
|
||||
|
||||
fn from_form_data(data: &[u8]) -> Result<Self, ServerFnError> {
|
||||
let data = ::leptos::leptos_server::form_urlencoded::parse(data).collect::<Vec<_>>();
|
||||
let data = ::leptos::form_urlencoded::parse(data).collect::<Vec<_>>();
|
||||
Ok(Self {
|
||||
#(#from_form_data_fields),*
|
||||
})
|
||||
@@ -129,8 +129,8 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
fn call_fn_client(self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, ::leptos::ServerFnError>>>> {
|
||||
let #struct_name { #(#field_names_3),* } = self;
|
||||
Box::pin(async move { #fn_name( #(#field_names_4),*).await })
|
||||
let #struct_name { #(#field_names_3),* } = self;
|
||||
Box::pin(async move { #fn_name( #(#field_names_4),*).await })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,97 +207,4 @@ impl Parse for ServerFnBody {
|
||||
attrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
/*
|
||||
/// Serialize the same way, regardless of flavor
|
||||
impl ToTokens for ServerFnBody {
|
||||
fn to_tokens(&self, out_tokens: &mut TokenStream2) {
|
||||
let Self {
|
||||
vis,
|
||||
ident,
|
||||
generics,
|
||||
inputs,
|
||||
output,
|
||||
where_clause,
|
||||
block,
|
||||
attrs,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let fields = inputs.iter().map(|f| {
|
||||
let typed_arg = match f {
|
||||
FnArg::Receiver(_) => todo!(),
|
||||
FnArg::Typed(t) => t,
|
||||
};
|
||||
if let Type::Path(pat) = &*typed_arg.ty {
|
||||
if pat.path.segments[0].ident == "Option" {
|
||||
quote! {
|
||||
#[builder(default, setter(strip_option))]
|
||||
#vis #f
|
||||
}
|
||||
} else {
|
||||
quote! { #vis #f }
|
||||
}
|
||||
} else {
|
||||
quote! { #vis #f }
|
||||
}
|
||||
});
|
||||
|
||||
let struct_name = Ident::new(&format!("{}Props", ident), Span::call_site());
|
||||
|
||||
let field_names = inputs.iter().filter_map(|f| match f {
|
||||
FnArg::Receiver(_) => todo!(),
|
||||
FnArg::Typed(t) => Some(&t.pat),
|
||||
});
|
||||
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
out_tokens.append_all(quote! {
|
||||
#[derive(Copy, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Debug)]
|
||||
pub struct #struct_name {}
|
||||
|
||||
#[async_trait]
|
||||
impl ServerFn for #struct_name {
|
||||
type Output = i32;
|
||||
|
||||
fn url() -> &'static str {
|
||||
"get_server_count"
|
||||
}
|
||||
|
||||
fn as_form_data(&self) -> Vec<(&'static str, String)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn call_fn(self) -> Result<Self::Output, ServerFnError> {
|
||||
get_server_count().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_server_count() -> Result<i32, ServerFnError> {
|
||||
Ok(COUNT.load(Ordering::Relaxed))
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn get_server_count() -> Result<i32, ServerFnError> {
|
||||
call_server_fn(#struct_name::url(), #struct_name {}).await
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn get_server_count_helper(args: #struct_name) -> Result<i32, ServerFnError> {
|
||||
call_server_fn(#struct_name::url(), args).await
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
*/
|
||||
}
|
||||
@@ -152,7 +152,7 @@ fn root_element_to_tokens(
|
||||
#[derive(Clone, Debug)]
|
||||
enum PrevSibChange {
|
||||
Sib(Ident),
|
||||
//Parent,
|
||||
Parent,
|
||||
Skip,
|
||||
}
|
||||
|
||||
@@ -214,8 +214,14 @@ fn element_to_tokens(
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
node.name_as_string().and_then(|name| {
|
||||
if name.starts_with("class:") {
|
||||
let name = name.replacen("class:", "", 1);
|
||||
if name.starts_with("class:") || name.starts_with("class-") {
|
||||
let name = if name.starts_with("class:") {
|
||||
name.replacen("class:", "", 1)
|
||||
} else if name.starts_with("class-") {
|
||||
name.replacen("class-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = node.value.as_ref().expect("class: attributes need values");
|
||||
let span = node.name_span().expect("missing span for class name");
|
||||
Some(quote_spanned! {
|
||||
@@ -270,7 +276,7 @@ fn element_to_tokens(
|
||||
quote_spanned! {
|
||||
span => let #this_el_ident = #debug_name;
|
||||
let #this_el_ident = #parent.clone().unchecked_into::<web_sys::Node>();
|
||||
//log::debug!("=> got {}", #this_el_ident.node_name());
|
||||
//debug!("=> got {}", #this_el_ident.node_name());
|
||||
}
|
||||
} else if let Some(prev_sib) = &prev_sib {
|
||||
quote_spanned! {
|
||||
@@ -345,11 +351,12 @@ fn element_to_tokens(
|
||||
expressions,
|
||||
multi,
|
||||
mode,
|
||||
idx == 0
|
||||
);
|
||||
|
||||
prev_sib = match curr_id {
|
||||
PrevSibChange::Sib(id) => Some(id),
|
||||
//PrevSibChange::Parent => None,
|
||||
PrevSibChange::Parent => None,
|
||||
PrevSibChange::Skip => prev_sib,
|
||||
};
|
||||
}
|
||||
@@ -400,6 +407,13 @@ fn attr_to_tokens(
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let name = if name.starts_with("attr:") {
|
||||
name.replacen("attr:", "", 1)
|
||||
} else if name.starts_with("attr-") {
|
||||
name.replacen("attr-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = match &node.value {
|
||||
Some(expr) => match expr {
|
||||
syn::Expr::Lit(expr_lit) => {
|
||||
@@ -456,16 +470,20 @@ fn attr_to_tokens(
|
||||
}
|
||||
}
|
||||
// Event Handlers
|
||||
else if name.starts_with("on:") {
|
||||
else if name.starts_with("on:") || name.starts_with("on-") {
|
||||
let handler = node
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("event listener attributes need a value");
|
||||
|
||||
if mode != Mode::Ssr {
|
||||
let event_name = name.replacen("on:", "", 1);
|
||||
let name = if name.starts_with("on:") {
|
||||
name.replacen("on:", "", 1)
|
||||
} else {
|
||||
name.replacen("on-", "", 1)
|
||||
};
|
||||
expressions.push(quote_spanned! {
|
||||
span => add_event_listener(#el_id.unchecked_ref(), #event_name, #handler);
|
||||
span => add_event_listener(#el_id.unchecked_ref(), #name, #handler);
|
||||
});
|
||||
} else {
|
||||
// this is here to avoid warnings about unused signals
|
||||
@@ -476,10 +494,14 @@ fn attr_to_tokens(
|
||||
}
|
||||
}
|
||||
// Properties
|
||||
else if name.starts_with("prop:") {
|
||||
else if name.starts_with("prop:") || name.starts_with("prop-") {
|
||||
let name = if name.starts_with("prop:") {
|
||||
name.replacen("prop:", "", 1)
|
||||
} else {
|
||||
name.replacen("prop-", "", 1)
|
||||
};
|
||||
// can't set properties in SSR
|
||||
if mode != Mode::Ssr {
|
||||
let name = name.replacen("prop:", "", 1);
|
||||
if mode != Mode::Ssr {
|
||||
let value = node.value.as_ref().expect("prop: blocks need values");
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
@@ -487,11 +509,15 @@ fn attr_to_tokens(
|
||||
}
|
||||
}
|
||||
// Classes
|
||||
else if name.starts_with("class:") {
|
||||
else if name.starts_with("class:") || name.starts_with("class-") {
|
||||
let name = if name.starts_with("class:") {
|
||||
name.replacen("class:", "", 1)
|
||||
} else {
|
||||
name.replacen("class-", "", 1)
|
||||
};
|
||||
if mode == Mode::Ssr {
|
||||
// handled separately because they need to be merged
|
||||
} else {
|
||||
let name = name.replacen("class:", "", 1);
|
||||
let value = node.value.as_ref().expect("class: attributes need values");
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos_dom::class(#cx, #el_id.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
@@ -571,6 +597,7 @@ fn child_to_tokens(
|
||||
expressions: &mut Vec<TokenStream>,
|
||||
multi: bool,
|
||||
mode: Mode,
|
||||
is_first_child: bool
|
||||
) -> PrevSibChange {
|
||||
match node.node_type {
|
||||
NodeType::Element => {
|
||||
@@ -588,6 +615,7 @@ fn child_to_tokens(
|
||||
next_co_id,
|
||||
multi,
|
||||
mode,
|
||||
is_first_child
|
||||
)
|
||||
} else {
|
||||
PrevSibChange::Sib(element_to_tokens(
|
||||
@@ -744,6 +772,7 @@ fn component_to_tokens(
|
||||
next_co_id: &mut usize,
|
||||
multi: bool,
|
||||
mode: Mode,
|
||||
is_first_child: bool
|
||||
) -> PrevSibChange {
|
||||
let create_component = create_component(cx, node, mode);
|
||||
let span = node.name_span().unwrap();
|
||||
@@ -831,7 +860,11 @@ fn component_to_tokens(
|
||||
|
||||
match current {
|
||||
Some(el) => PrevSibChange::Sib(el),
|
||||
None => PrevSibChange::Skip,
|
||||
None => if is_first_child {
|
||||
PrevSibChange::Parent
|
||||
} else {
|
||||
PrevSibChange::Skip
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,9 +908,13 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
let props = node.attributes.iter().filter_map(|attr| {
|
||||
let attr_name = attr.name_as_string().unwrap_or_default();
|
||||
if attr_name.starts_with("on:")
|
||||
|| attr_name.starts_with("on-")
|
||||
|| attr_name.starts_with("prop:")
|
||||
|| attr_name.starts_with("prop-")
|
||||
|| attr_name.starts_with("class:")
|
||||
|| attr_name.starts_with("class-")
|
||||
|| attr_name.starts_with("attr:")
|
||||
|| attr_name.starts_with("attr-")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -896,12 +933,23 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
|
||||
let mut other_attrs = node.attributes.iter().filter_map(|attr| {
|
||||
let attr_name = attr.name_as_string().unwrap_or_default();
|
||||
// Event Listeners
|
||||
if let Some(event_name) = attr_name.strip_prefix("on:") {
|
||||
let span = attr.name_span().unwrap();
|
||||
let handler = attr
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("event listener attributes need a value");
|
||||
.expect("on: event listener attributes need a value");
|
||||
Some(quote_spanned! {
|
||||
span => add_event_listener(#component_name.unchecked_ref(), #event_name, #handler)
|
||||
})
|
||||
}
|
||||
else if let Some(event_name) = attr_name.strip_prefix("on-") {
|
||||
let span = attr.name_span().unwrap();
|
||||
let handler = attr
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("on- event listener attributes need a value");
|
||||
Some(quote_spanned! {
|
||||
span => add_event_listener(#component_name.unchecked_ref(), #event_name, #handler)
|
||||
})
|
||||
@@ -913,6 +961,12 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
span => leptos_dom::property(#cx, #component_name.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("prop-") {
|
||||
let value = attr.value.as_ref().expect("prop- attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::property(#cx, #component_name.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
})
|
||||
}
|
||||
// Classes
|
||||
else if let Some(name) = attr_name.strip_prefix("class:") {
|
||||
let value = attr.value.as_ref().expect("class: attributes need values");
|
||||
@@ -920,10 +974,21 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
span => leptos_dom::class(#cx, #component_name.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("class-") {
|
||||
let value = attr.value.as_ref().expect("class: attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::class(#cx, #component_name.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
})
|
||||
}
|
||||
// Attributes
|
||||
else if let Some(name) = attr_name.strip_prefix("attr:") {
|
||||
let value = attr.value.as_ref().expect("attr: attributes need values");
|
||||
let name = name.replace('_', "-");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::attribute(#cx, #component_name.unchecked_ref(), #name, #value.into_attribute(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("attr-") {
|
||||
let value = attr.value.as_ref().expect("attr- attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::attribute(#cx, #component_name.unchecked_ref(), #name, #value.into_attribute(#cx))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_reactive"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
[package]
|
||||
name = "leptos_server"
|
||||
version = "0.0.11"
|
||||
version = "0.0.15"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gbj/leptos"
|
||||
description = "RPC for the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
form_urlencoded = "1"
|
||||
gloo-net = "0.2"
|
||||
lazy_static = "1"
|
||||
@@ -13,11 +17,35 @@ linear-map = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0" }
|
||||
leptos = { path = "../leptos", default-features = false, version = "0.0" }
|
||||
|
||||
[features]
|
||||
csr = ["leptos_dom/csr", "leptos_reactive/csr"]
|
||||
hydrate = ["leptos_dom/hydrate", "leptos_reactive/hydrate"]
|
||||
ssr = ["leptos_dom/ssr", "leptos_reactive/ssr"]
|
||||
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
|
||||
csr = [
|
||||
"leptos_dom/csr",
|
||||
"leptos_reactive/csr",
|
||||
"leptos_macro/csr",
|
||||
"leptos/csr",
|
||||
]
|
||||
hydrate = [
|
||||
"leptos_dom/hydrate",
|
||||
"leptos_reactive/hydrate",
|
||||
"leptos_macro/hydrate",
|
||||
"leptos/hydrate",
|
||||
]
|
||||
ssr = [
|
||||
"leptos_dom/ssr",
|
||||
"leptos_reactive/ssr",
|
||||
"leptos_macro/ssr",
|
||||
"leptos/csr",
|
||||
]
|
||||
stable = [
|
||||
"leptos_dom/stable",
|
||||
"leptos_reactive/stable",
|
||||
"leptos_macro/stable",
|
||||
"leptos/stable",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["stable"]
|
||||
|
||||
@@ -56,16 +56,6 @@
|
||||
//! - **Server function arguments and return types must be [Serializable](leptos_reactive::Serializable).**
|
||||
//! This should be fairly obvious: we have to serialize arguments to send them to the server, and we
|
||||
//! need to deserialize the result to return it to the client.
|
||||
//!
|
||||
//! ### `create_action`
|
||||
//!
|
||||
//! The easiest way to call server functions from the client is the `create_action` primitive.
|
||||
//! This returns an [Action](crate::Action), with a [dispatch](crate::Action::dispatch) method
|
||||
//! that can run any `async` function, including one that contains one or more calls to server functions.
|
||||
//!
|
||||
//! Dispatching an action increments its [version](crate::Action::version) field, which is a
|
||||
//! signal. This is very useful, as it can be used to invalidate a [Resource](leptos_reactive::Resource)
|
||||
//! that reads from the same data.
|
||||
|
||||
pub use form_urlencoded;
|
||||
use leptos_reactive::*;
|
||||
@@ -73,22 +63,65 @@ use serde::{Deserialize, Serialize};
|
||||
use std::{future::Future, pin::Pin, rc::Rc};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
type ServerFnTraitObj =
|
||||
dyn Fn(&[u8]) -> Pin<Box<dyn Future<Output = Result<String, ServerFnError>>>> + Send + Sync;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, Arc<ServerFnTraitObj>>>> = Default::default();
|
||||
static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, Arc<ServerFnTraitObj>>>> = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Attempts to find a server function registered at the given path.
|
||||
///
|
||||
/// This can be used by a server to handle the requests, as in the following example (using `actix-web`)
|
||||
///
|
||||
/// ```rust, ignore
|
||||
/// #[post("{tail:.*}")]
|
||||
/// async fn handle_server_fns(
|
||||
/// req: HttpRequest,
|
||||
/// params: web::Path<String>,
|
||||
/// body: web::Bytes,
|
||||
/// ) -> impl Responder {
|
||||
/// let path = params.into_inner();
|
||||
/// let accept_header = req
|
||||
/// .headers()
|
||||
/// .get("Accept")
|
||||
/// .and_then(|value| value.to_str().ok());
|
||||
///
|
||||
/// if let Some(server_fn) = server_fn_by_path(path.as_str()) {
|
||||
/// let body: &[u8] = &body;
|
||||
/// match server_fn(&body).await {
|
||||
/// Ok(serialized) => {
|
||||
/// // if this is Accept: application/json then send a serialized JSON response
|
||||
/// if let Some("application/json") = accept_header {
|
||||
/// HttpResponse::Ok().body(serialized)
|
||||
/// }
|
||||
/// // otherwise, it's probably a <form> submit or something: redirect back to the referrer
|
||||
/// else {
|
||||
/// HttpResponse::SeeOther()
|
||||
/// .insert_header(("Location", "/"))
|
||||
/// .content_type("application/json")
|
||||
/// .body(serialized)
|
||||
/// }
|
||||
/// }
|
||||
/// Err(e) => {
|
||||
/// eprintln!("server function error: {e:#?}");
|
||||
/// HttpResponse::InternalServerError().body(e.to_string())
|
||||
/// }
|
||||
/// }
|
||||
/// } else {
|
||||
/// HttpResponse::BadRequest().body(format!("Could not find a server function at that route."))
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
pub fn server_fn_by_path(path: &str) -> Option<Arc<ServerFnTraitObj>> {
|
||||
REGISTERED_SERVER_FUNCTIONS
|
||||
.read()
|
||||
@@ -96,25 +129,43 @@ pub fn server_fn_by_path(path: &str) -> Option<Arc<ServerFnTraitObj>> {
|
||||
.and_then(|fns| fns.get(path).cloned())
|
||||
}
|
||||
|
||||
/// Defines a "server function." A server function can be called from the server or the client,
|
||||
/// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` is enabled.
|
||||
///
|
||||
/// (This follows the same convention as the Leptos framework's distinction between `ssr` for server-side rendering,
|
||||
/// and `csr` and `hydrate` for client-side rendering and hydration, respectively.)
|
||||
///
|
||||
/// Server functions are created using the `server` macro.
|
||||
///
|
||||
/// The function should be registered by calling `ServerFn::register()`. The set of server functions
|
||||
/// can be queried on the server for routing purposes by calling [server_fn_by_path].
|
||||
///
|
||||
/// Technically, the trait is implemented on a type that describes the server function's arguments.
|
||||
pub trait ServerFn
|
||||
where
|
||||
Self: Sized + 'static,
|
||||
{
|
||||
type Output: Serializable;
|
||||
|
||||
/// The path at which the server function can be reached on the server.
|
||||
fn url() -> &'static str;
|
||||
|
||||
/// A set of `(input_name, input_value)` pairs used to serialize the arguments to the server function.
|
||||
fn as_form_data(&self) -> Vec<(&'static str, String)>;
|
||||
|
||||
/// Deserializes the arguments to the server function from form data.
|
||||
fn from_form_data(data: &[u8]) -> Result<Self, ServerFnError>;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Runs the function on the server.
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
fn call_fn(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>> + Send>>;
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
/// Runs the function on the client by sending an HTTP request to the server.
|
||||
#[cfg(any(not(feature = "ssr"), doc))]
|
||||
fn call_fn_client(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>>>>;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Registers the server function, allowing the server to query it by URL.
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
fn register() -> Result<(), ServerFnError> {
|
||||
// create the handler for this server function
|
||||
// takes a String -> returns its async value
|
||||
@@ -156,6 +207,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Type for errors that can occur when using server functions.
|
||||
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ServerFnError {
|
||||
#[error("error while trying to register the server function: {0}")]
|
||||
@@ -174,6 +226,7 @@ pub enum ServerFnError {
|
||||
MissingArg(String),
|
||||
}
|
||||
|
||||
/// Executes the HTTP call to call a server function from the client, given its URL and argument type.
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn call_server_fn<T>(url: &str, args: impl ServerFn) -> Result<T, ServerFnError>
|
||||
where
|
||||
@@ -213,12 +266,87 @@ where
|
||||
T::from_json(&text).map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
}
|
||||
|
||||
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [Resource](leptos_reactive::Resource) instead. If you’re trying to occasionally
|
||||
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// How many times the action has successfully resolved.
|
||||
pub version: RwSignal<usize>,
|
||||
input: RwSignal<Option<I>>,
|
||||
value: RwSignal<Option<O>>,
|
||||
@@ -233,44 +361,129 @@ where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
self.url = Some(T::url());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> impl Fn() -> bool {
|
||||
let value = self.value;
|
||||
move || value.with(|val| val.is_some())
|
||||
}
|
||||
|
||||
pub fn input(&self) -> ReadSignal<Option<I>> {
|
||||
self.input.read_only()
|
||||
}
|
||||
|
||||
pub fn value(&self) -> ReadSignal<Option<O>> {
|
||||
self.value.read_only()
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url
|
||||
}
|
||||
|
||||
/// Calls the server function a reference to the input type as its argument.
|
||||
pub fn dispatch(&self, input: I) {
|
||||
let fut = (self.action_fn)(&input);
|
||||
self.input.set(Some(input));
|
||||
let input = self.input;
|
||||
let version = self.version;
|
||||
let pending = self.pending;
|
||||
let value = self.value;
|
||||
pending.set(true);
|
||||
spawn_local(async move {
|
||||
let new_value = fut.await;
|
||||
value.set(Some(new_value));
|
||||
input.set(None);
|
||||
pending.set(false);
|
||||
value.set(Some(new_value));
|
||||
version.update(|n| *n += 1);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
|
||||
pub fn pending(&self) -> ReadSignal<bool> {
|
||||
self.pending.read_only()
|
||||
}
|
||||
|
||||
/// The argument that was dispatched to the `async` function,
|
||||
/// only while we are waiting for it to resolve.
|
||||
pub fn input(&self) -> ReadSignal<Option<I>> {
|
||||
self.input.read_only()
|
||||
}
|
||||
|
||||
/// The most recent return value of the `async` function.
|
||||
pub fn value(&self) -> ReadSignal<Option<O>> {
|
||||
self.value.read_only()
|
||||
}
|
||||
|
||||
/// The URL associated with the action (typically as part of a server function.)
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url
|
||||
}
|
||||
|
||||
/// Associates the URL of the given server function with this action.
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
self.url = Some(T::url());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If you’re trying
|
||||
/// to occasionally run an `async` function in response to something like a user clicking a button,
|
||||
/// you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
@@ -297,6 +510,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] that can be used to call a server function.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
|
||||
/// # use leptos_macro::server;
|
||||
///
|
||||
/// #[server(MyServerFn)]
|
||||
/// async fn my_server_fn() -> Result<(), ServerFnError> {
|
||||
/// todo!()
|
||||
/// }
|
||||
///
|
||||
/// # run_scope(|cx| {
|
||||
/// let my_server_action = create_server_action::<MyServerFn>(cx);
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
|
||||
where
|
||||
S: Clone + ServerFn,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_router"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -6,23 +6,39 @@ use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::{use_navigate, use_resolved_path, ToHref};
|
||||
|
||||
/// Properties that can be passed to the [Form] component, which is an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct FormProps<A>
|
||||
where
|
||||
A: ToHref + 'static,
|
||||
{
|
||||
/// [`method`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-method)
|
||||
/// is the HTTP method to submit the form with (`get` or `post`).
|
||||
#[builder(default, setter(strip_option))]
|
||||
method: Option<&'static str>,
|
||||
action: A,
|
||||
pub method: Option<&'static str>,
|
||||
/// [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-action)
|
||||
/// is the URL that processes the form submission. Takes a [String], [&str], or a reactive
|
||||
/// function that returns a [String].
|
||||
pub action: A,
|
||||
/// [`enctype`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype)
|
||||
/// is the MIME type of the form submission if `method` is `post`.
|
||||
#[builder(default, setter(strip_option))]
|
||||
enctype: Option<String>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
pub enctype: Option<String>,
|
||||
/// A signal that will be incremented whenever the form is submitted with `post`. This can useful
|
||||
/// for reactively updating a [Resource] or another signal whenever the form has been submitted.
|
||||
#[builder(default, setter(strip_option))]
|
||||
version: Option<RwSignal<usize>>,
|
||||
pub version: Option<RwSignal<usize>>,
|
||||
/// A signal that will be set if the form submission ends in an error.
|
||||
#[builder(default, setter(strip_option))]
|
||||
error: Option<RwSignal<Option<Box<dyn Error>>>>,
|
||||
pub error: Option<RwSignal<Option<Box<dyn Error>>>>,
|
||||
/// Component children; should include the HTML of the form elements.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// An HTML [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) progressively
|
||||
/// enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Form<A>(cx: Scope, props: FormProps<A>) -> Element
|
||||
where
|
||||
@@ -159,6 +175,9 @@ where
|
||||
if let Some(version) = action_version {
|
||||
version.update(|n| *n += 1);
|
||||
}
|
||||
if let Some(error) = error {
|
||||
error.set(None);
|
||||
}
|
||||
|
||||
if resp.status() == 303 {
|
||||
if let Some(redirect_url) = resp.headers().get("Location") {
|
||||
@@ -190,16 +209,27 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties that can be passed to the [ActionForm] component, which
|
||||
/// automatically turns a server [Action](leptos_server::Action) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct ActionFormProps<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
action: Action<I, O>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
/// The action from which to build the form. This should include a URL, which can be generated
|
||||
/// by default using [create_server_action](leptos_server::create_server_action) or added
|
||||
/// manually using [leptos_server::Action::using_server_fn].
|
||||
pub action: Action<I, O>,
|
||||
/// Component children; should include the HTML of the form elements.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// Automatically turns a server [Action](leptos_server::Action) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ActionForm<I, O>(cx: Scope, props: ActionFormProps<I, O>) -> Element
|
||||
where
|
||||
|
||||
@@ -3,12 +3,16 @@ use leptos::leptos_dom::IntoChild;
|
||||
use leptos::*;
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
#[cfg(any(feature = "csr", feature = "hydrate"))]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::{use_location, use_resolved_path, State};
|
||||
|
||||
/// Describes a value that is either a static or a reactive URL, i.e.,
|
||||
/// a [String], a [&str], or a reactive `Fn() -> String`.
|
||||
pub trait ToHref {
|
||||
/// Converts the (static or reactive) URL into a function that can be called to
|
||||
/// return the URL.
|
||||
fn to_href(&self) -> Box<dyn Fn() -> String + '_>;
|
||||
}
|
||||
|
||||
@@ -35,6 +39,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties that can be passed to the [A] component, which is an HTML
|
||||
/// [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct AProps<C, H>
|
||||
where
|
||||
@@ -43,21 +50,24 @@ where
|
||||
{
|
||||
/// Used to calculate the link's `href` attribute. Will be resolved relative
|
||||
/// to the current route.
|
||||
href: H,
|
||||
pub href: H,
|
||||
/// If `true`, the link is marked active when the location matches exactly;
|
||||
/// if false, link is marked active if the current route starts with it.
|
||||
#[builder(default)]
|
||||
exact: bool,
|
||||
pub exact: bool,
|
||||
/// An object of any type that will be pushed to router state
|
||||
#[builder(default, setter(strip_option))]
|
||||
state: Option<State>,
|
||||
pub state: Option<State>,
|
||||
/// If `true`, the link will not add to the browser's history (so, pressing `Back`
|
||||
/// will skip this page.)
|
||||
#[builder(default)]
|
||||
replace: bool,
|
||||
children: Box<dyn Fn() -> Vec<C>>,
|
||||
pub replace: bool,
|
||||
/// The nodes or elements to be shown inside the link.
|
||||
pub children: Box<dyn Fn() -> Vec<C>>,
|
||||
}
|
||||
|
||||
/// An HTML [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn A<C, H>(cx: Scope, props: AProps<C, H>) -> Element
|
||||
where
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::use_route;
|
||||
use leptos::*;
|
||||
|
||||
/// Displays the child route nested in a parent route, allowing you to control exactly where
|
||||
/// that child route is displayed. Renders nothing if there is no nested child.
|
||||
#[component]
|
||||
pub fn Outlet(cx: Scope) -> Child {
|
||||
let route = use_route(cx);
|
||||
|
||||
@@ -5,27 +5,33 @@ use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::{
|
||||
matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
|
||||
Action, Loader, ParamsMap, RouterContext,
|
||||
ParamsMap, RouterContext,
|
||||
};
|
||||
|
||||
pub struct ChildlessRoute {}
|
||||
|
||||
/// Properties that can be passed to a [Route] component, which describes
|
||||
/// a portion of the nested layout of the app, specifying the route it should match,
|
||||
/// the element it should display, and data that should be loaded alongside the route.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RouteProps<E, F>
|
||||
where
|
||||
E: IntoChild,
|
||||
F: Fn(Scope) -> E + 'static,
|
||||
{
|
||||
path: &'static str,
|
||||
element: F,
|
||||
/// The path fragment that this route should match. This can be static (`users`),
|
||||
/// include a parameter (`:id`) or an optional parameter (`:id?`), or match a
|
||||
/// wildcard (`user/*any`).
|
||||
pub path: &'static str,
|
||||
/// The view that should be shown when this route is matched. This can be any function
|
||||
/// that takes a [Scope] and returns an [Element] (like `|cx| view! { cx, <p>"Show this"</p> })`
|
||||
/// or `|cx| view! { cx, <MyComponent/>` } or even, for a component with no props, `MyComponent`).
|
||||
pub element: F,
|
||||
/// `children` may be empty or include nested routes.
|
||||
#[builder(default, setter(strip_option))]
|
||||
loader: Option<Loader>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
action: Option<Action>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
|
||||
pub children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
|
||||
}
|
||||
|
||||
/// Describes a portion of the nested layout of the app, specifying the route it should match,
|
||||
/// the element it should display, and data that should be loaded alongside the route.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Route<E, F>(_cx: Scope, props: RouteProps<E, F>) -> RouteDefinition
|
||||
where
|
||||
@@ -34,13 +40,12 @@ where
|
||||
{
|
||||
RouteDefinition {
|
||||
path: props.path,
|
||||
loader: props.loader,
|
||||
action: props.action,
|
||||
children: props.children.map(|c| c()).unwrap_or_default(),
|
||||
element: Rc::new(move |cx| (props.element)(cx).into_child(cx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Context type that contains information about the current, matched route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RouteContext {
|
||||
inner: Rc<RouteContextInner>,
|
||||
@@ -57,12 +62,7 @@ impl RouteContext {
|
||||
let base = base.path();
|
||||
let RouteMatch { path_match, route } = matcher()?;
|
||||
let PathMatch { path, .. } = path_match;
|
||||
let RouteDefinition {
|
||||
element,
|
||||
loader,
|
||||
action,
|
||||
..
|
||||
} = route.key;
|
||||
let RouteDefinition { element, .. } = route.key;
|
||||
let params = create_memo(cx, move |_| {
|
||||
matcher()
|
||||
.map(|matched| matched.path_match.params)
|
||||
@@ -74,8 +74,6 @@ impl RouteContext {
|
||||
cx,
|
||||
base_path: base.to_string(),
|
||||
child: Box::new(child),
|
||||
loader,
|
||||
action,
|
||||
path,
|
||||
original_path: route.original_path.to_string(),
|
||||
params,
|
||||
@@ -84,30 +82,27 @@ impl RouteContext {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the reactive scope of the current route.
|
||||
pub fn cx(&self) -> Scope {
|
||||
self.inner.cx
|
||||
}
|
||||
|
||||
/// Returns the URL path of the current route.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.inner.path
|
||||
}
|
||||
|
||||
/// A reactive wrapper for the route parameters that are currently matched.
|
||||
pub fn params(&self) -> Memo<ParamsMap> {
|
||||
self.inner.params
|
||||
}
|
||||
|
||||
pub fn loader(&self) -> &Option<Loader> {
|
||||
&self.inner.loader
|
||||
}
|
||||
|
||||
pub fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
|
||||
pub(crate) fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(RouteContextInner {
|
||||
cx,
|
||||
base_path: path.to_string(),
|
||||
child: Box::new(|| None),
|
||||
loader: None,
|
||||
action: None,
|
||||
path: path.to_string(),
|
||||
original_path: path.to_string(),
|
||||
params: create_memo(cx, |_| ParamsMap::new()),
|
||||
@@ -116,14 +111,17 @@ impl RouteContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a relative route, relative to the current route's path.
|
||||
pub fn resolve_path<'a>(&'a self, to: &'a str) -> Option<Cow<'a, str>> {
|
||||
resolve_path(&self.inner.base_path, to, Some(&self.inner.path))
|
||||
}
|
||||
|
||||
/// The nested child route, if any.
|
||||
pub fn child(&self) -> Option<RouteContext> {
|
||||
(self.inner.child)()
|
||||
}
|
||||
|
||||
/// The view associated with the current route.
|
||||
pub fn outlet(&self) -> impl IntoChild {
|
||||
(self.inner.outlet)()
|
||||
}
|
||||
@@ -133,8 +131,6 @@ pub(crate) struct RouteContextInner {
|
||||
cx: Scope,
|
||||
base_path: String,
|
||||
pub(crate) child: Box<dyn Fn() -> Option<RouteContext>>,
|
||||
pub(crate) loader: Option<Loader>,
|
||||
pub(crate) action: Option<Action>,
|
||||
pub(crate) path: String,
|
||||
pub(crate) original_path: String,
|
||||
pub(crate) params: Memo<ParamsMap>,
|
||||
@@ -160,31 +156,3 @@ impl std::fmt::Debug for RouteContextInner {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoChildRoutes {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition>;
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for () {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for RouteDefinition {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
vec![self]
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for Option<RouteDefinition> {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
self.map(|c| vec![c]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for Vec<RouteDefinition> {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,23 @@ use crate::{
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
use crate::{unescape, Url};
|
||||
|
||||
/// Props for the [Router] component, which sets up client-side and server-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RouterProps {
|
||||
/// The base URL for the router. Defaults to "".
|
||||
#[builder(default, setter(strip_option))]
|
||||
base: Option<&'static str>,
|
||||
pub base: Option<&'static str>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
fallback: Option<fn() -> Element>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
/// A fallback that should be shown if no route is matched.
|
||||
pub fallback: Option<fn() -> Element>,
|
||||
/// The `<Router/>` should usually wrap your whole page. It can contain
|
||||
/// any elements, and should include a [Routes](crate::Routes) component somewhere
|
||||
/// to define and display [Route](crate::Route)s.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// Provides for client-side and server-side routing. This should usually be somewhere near
|
||||
/// the root of the application.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
|
||||
// create a new RouterContext and provide it to every component beneath the router
|
||||
@@ -38,6 +46,7 @@ pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
|
||||
props.children
|
||||
}
|
||||
|
||||
/// Context type that contains information about the current router state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouterContext {
|
||||
pub(crate) inner: Rc<RouterContextInner>,
|
||||
@@ -72,7 +81,11 @@ impl std::fmt::Debug for RouterContextInner {
|
||||
}
|
||||
|
||||
impl RouterContext {
|
||||
pub fn new(cx: Scope, base: Option<&'static str>, fallback: Option<fn() -> Element>) -> Self {
|
||||
pub(crate) fn new(
|
||||
cx: Scope,
|
||||
base: Option<&'static str>,
|
||||
fallback: Option<fn() -> Element>,
|
||||
) -> Self {
|
||||
cfg_if! {
|
||||
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
|
||||
let history = use_context::<RouterIntegrationContext>(cx)
|
||||
@@ -157,10 +170,12 @@ impl RouterContext {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// The current [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname).
|
||||
pub fn pathname(&self) -> Memo<String> {
|
||||
self.inner.location.pathname
|
||||
}
|
||||
|
||||
/// The [RouteContext] of the base route.
|
||||
pub fn base(&self) -> RouteContext {
|
||||
self.inner.base.clone()
|
||||
}
|
||||
@@ -190,7 +205,7 @@ impl RouterContextInner {
|
||||
return Err(NavigationError::MaxRedirects);
|
||||
}
|
||||
|
||||
if resolved_to != (this.reference)() || options.state != (this.state).get() {
|
||||
if resolved_to != this.reference.get() || options.state != (this.state).get() {
|
||||
if cfg!(feature = "server") {
|
||||
// TODO server out
|
||||
self.history.navigate(&LocationChange {
|
||||
@@ -336,18 +351,30 @@ impl RouterContextInner {
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that occurs during navigation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NavigationError {
|
||||
/// The given path is not routable.
|
||||
#[error("Path {0:?} is not routable")]
|
||||
NotRoutable(String),
|
||||
/// Too many redirects occurred during routing (prevents and infinite loop.)
|
||||
#[error("Too many redirects")]
|
||||
MaxRedirects,
|
||||
}
|
||||
|
||||
/// Options that can be used to configure a navigation. Used with [use_navigate](crate::use_navigate).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NavigateOptions {
|
||||
/// Whether the URL being navigated to should be resolved relative to the current route.
|
||||
pub resolve: bool,
|
||||
/// If `true` the new location will replace the current route in the history stack, meaning
|
||||
/// the "back" button will skip over the current route. (Defaults to `false`).
|
||||
pub replace: bool,
|
||||
/// If `true`, the router will scroll to the top of the window at the end of navigation.
|
||||
/// Defaults to `true.
|
||||
pub scroll: bool,
|
||||
/// [State](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that should be pushed
|
||||
/// onto the history stack during navigation.
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::{matching::{expand_optionals, join_paths, Branch, Matcher, RouteDefinition, get_route_matches, RouteMatch}, RouterContext, RouteContext};
|
||||
|
||||
/// Props for the [Routes] component, which contains route definitions and manages routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RoutesProps {
|
||||
#[builder(default, setter(strip_option))]
|
||||
@@ -12,6 +13,9 @@ pub struct RoutesProps {
|
||||
children: Box<dyn Fn() -> Vec<RouteDefinition>>,
|
||||
}
|
||||
|
||||
/// Contains route definitions and manages the actual routing process.
|
||||
///
|
||||
/// You should locate the `<Routes/>` component wherever on the page you want the routes to appear.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Routes(cx: Scope, props: RoutesProps) -> impl IntoChild {
|
||||
let router = use_context::<RouterContext>(cx).unwrap_or_else(|| {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{future::Future, rc::Rc};
|
||||
|
||||
use crate::{PinnedFuture, Request, Response};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Action {
|
||||
f: Rc<dyn Fn(&Request) -> PinnedFuture<Response>>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub async fn send(&self, req: &Request) -> Response {
|
||||
(self.f)(req).await
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, Fu> From<F> for Action
|
||||
where
|
||||
F: Fn(&Request) -> Fu + 'static,
|
||||
Fu: Future<Output = Response> + 'static,
|
||||
{
|
||||
fn from(f: F) -> Self {
|
||||
Self {
|
||||
f: Rc::new(move |req| Box::pin(f(req))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Action {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Action").finish()
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
use std::{any::Any, fmt::Debug, future::Future, rc::Rc};
|
||||
|
||||
use leptos::*;
|
||||
|
||||
use crate::{use_location, use_params_map, use_route, ParamsMap, PinnedFuture, Url};
|
||||
|
||||
// SSR and CSR both do the work in their own environment and return it as a resource
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
|
||||
where
|
||||
T: Clone + Debug + Serializable + 'static,
|
||||
{
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx);
|
||||
let loader = route.loader().clone().unwrap_or_else(|| {
|
||||
debug_warn!(
|
||||
"use_loader() called on a route without a loader: {:?}",
|
||||
route.path()
|
||||
);
|
||||
panic!()
|
||||
});
|
||||
|
||||
let location = use_location(cx);
|
||||
let route = use_route(cx);
|
||||
let url = move || Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
|
||||
let loader = loader.data.clone();
|
||||
|
||||
create_resource(
|
||||
cx,
|
||||
move || (params.get(), url()),
|
||||
move |(params, url)| {
|
||||
let loader = loader.clone();
|
||||
async move {
|
||||
let any_data = (loader.clone())(cx, params, url).await;
|
||||
any_data
|
||||
.as_any()
|
||||
.downcast_ref::<T>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"use_loader() could not downcast to {:?}",
|
||||
std::any::type_name::<T>(),
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// In hydration mode, only run the loader on the server
|
||||
#[cfg(feature = "hydrate")]
|
||||
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
|
||||
where
|
||||
T: Clone + Debug + Serializable + 'static,
|
||||
{
|
||||
use wasm_bindgen::{JsCast, UnwrapThrowExt};
|
||||
|
||||
use crate::use_query_map;
|
||||
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx);
|
||||
|
||||
let location = use_location(cx);
|
||||
let route = use_route(cx);
|
||||
let url = move || Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
|
||||
create_resource(
|
||||
cx,
|
||||
move || (params.get(), url()),
|
||||
move |(params, url)| async move {
|
||||
let route = use_route(cx);
|
||||
let query = use_query_map(cx);
|
||||
|
||||
let mut opts = web_sys::RequestInit::new();
|
||||
opts.method("GET");
|
||||
let url = format!("{}{}", route.path(), query.get().to_query_string());
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init(&url, &opts).unwrap_throw();
|
||||
request
|
||||
.headers()
|
||||
.set("Accept", "application/json")
|
||||
.unwrap_throw();
|
||||
|
||||
let window = web_sys::window().unwrap_throw();
|
||||
let resp_value =
|
||||
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.unwrap_throw();
|
||||
let resp = resp_value.unchecked_into::<web_sys::Response>();
|
||||
let text = wasm_bindgen_futures::JsFuture::from(resp.text().unwrap_throw())
|
||||
.await
|
||||
.unwrap_throw()
|
||||
.as_string()
|
||||
.unwrap_throw();
|
||||
|
||||
T::from_json(&text).expect_throw(
|
||||
"couldn't deserialize loader data from serde-lite intermediate format",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub trait AnySerialize {
|
||||
fn serialize(&self) -> Option<String>;
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
impl<T> AnySerialize for T
|
||||
where
|
||||
T: Any + Serializable + 'static,
|
||||
{
|
||||
fn serialize(&self) -> Option<String> {
|
||||
self.to_json().ok()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Loader {
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub(crate) data: Rc<dyn Fn(Scope, ParamsMap, Url) -> PinnedFuture<Box<dyn AnySerialize>>>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn call_loader(&self, cx: Scope) -> PinnedFuture<Box<dyn AnySerialize>> {
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx).get();
|
||||
let location = use_location(cx);
|
||||
let url = Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
(self.data)(cx, params, url)
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, Fu, T> From<F> for Loader
|
||||
where
|
||||
F: Fn(Scope, ParamsMap, Url) -> Fu + 'static,
|
||||
Fu: Future<Output = T> + 'static,
|
||||
T: Any + Serializable + 'static,
|
||||
{
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
fn from(f: F) -> Self {
|
||||
let wrapped_fn = move |cx, params, url| {
|
||||
let res = f(cx, params, url);
|
||||
Box::pin(async move { Box::new(res.await) as Box<dyn AnySerialize> })
|
||||
as PinnedFuture<Box<dyn AnySerialize>>
|
||||
};
|
||||
|
||||
Self {
|
||||
data: Rc::new(wrapped_fn),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn from(f: F) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Loader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Loader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ssr", not(feature = "hydrate")))]
|
||||
pub async fn loader_to_json(view: impl Fn(Scope) -> String + 'static) -> Option<String> {
|
||||
let (data, _, disposer) = run_scope_undisposed(move |cx| async move {
|
||||
let _shell = view(cx);
|
||||
|
||||
let mut route = use_context::<crate::RouteContext>(cx)?;
|
||||
// get the innermost route matched by this path
|
||||
while let Some(child) = route.child() {
|
||||
route = child;
|
||||
}
|
||||
let data = route
|
||||
.loader()
|
||||
.as_ref()
|
||||
.map(|loader| loader.call_loader(cx))?;
|
||||
|
||||
data.await.serialize()
|
||||
});
|
||||
let data = data.await;
|
||||
disposer.dispose();
|
||||
data
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
mod action;
|
||||
mod loader;
|
||||
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
pub use action::*;
|
||||
pub use loader::*;
|
||||
|
||||
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
@@ -1,27 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum RouterError {
|
||||
#[error("loader found no data at this path")]
|
||||
NoMatch(String),
|
||||
#[error("route was matched, but loader returned None")]
|
||||
NotFound(String),
|
||||
#[error("could not find parameter {0}")]
|
||||
MissingParam(String),
|
||||
#[error("failed to deserialize parameters")]
|
||||
Params(Rc<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
|
||||
impl PartialEq for RouterError {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::NoMatch(l0), Self::NoMatch(r0)) => l0 == r0,
|
||||
(Self::NotFound(l0), Self::NotFound(r0)) => l0 == r0,
|
||||
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
|
||||
(Self::Params(_), Self::Params(_)) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::{State, Url};
|
||||
|
||||
use super::params::ParamsMap;
|
||||
|
||||
/// Creates a reactive location from the given path and state.
|
||||
pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<State>) -> Location {
|
||||
let url = create_memo(cx, move |prev: Option<&Url>| {
|
||||
path.with(|path| match Url::try_from(path.as_str()) {
|
||||
@@ -29,20 +30,33 @@ pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<St
|
||||
}
|
||||
}
|
||||
|
||||
/// A reactive description of the current URL, containing equivalents to the local parts of
|
||||
/// the browser's [`Location`](https://developer.mozilla.org/en-US/docs/Web/API/Location).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Location {
|
||||
pub query: Memo<ParamsMap>,
|
||||
/// The path of the URL, not containing the query string or hash fragment.
|
||||
pub pathname: Memo<String>,
|
||||
/// The raw query string.
|
||||
pub search: Memo<String>,
|
||||
/// The query string parsed into its key-value pairs.
|
||||
pub query: Memo<ParamsMap>,
|
||||
/// The hash fragment.
|
||||
pub hash: Memo<String>,
|
||||
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) at the top of the history stack.
|
||||
pub state: ReadSignal<State>,
|
||||
}
|
||||
|
||||
/// A description of a navigation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocationChange {
|
||||
/// The new URL.
|
||||
pub value: String,
|
||||
/// If true, the new location will replace the current one in the history stack, i.e.,
|
||||
/// clicking the "back" button will not return to the current location.
|
||||
pub replace: bool,
|
||||
/// If true, the router will scroll to the top of the page at the end of the navigation.
|
||||
pub scroll: bool,
|
||||
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that will be added during navigation.
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,19 @@ impl std::fmt::Debug for RouterIntegrationContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The [Router](crate::Router) relies on a [RouterIntegrationContext], which tells the router
|
||||
/// how to find things like the current URL, and how to navigate to a new page. The [History] trait
|
||||
/// can be implemented on any type to provide this information.
|
||||
pub trait History {
|
||||
/// A signal that updates whenever the current location changes.
|
||||
fn location(&self, cx: Scope) -> ReadSignal<LocationChange>;
|
||||
|
||||
/// Called to navigate to a new location.
|
||||
fn navigate(&self, loc: &LocationChange);
|
||||
}
|
||||
|
||||
/// The default integration when you are running in the browser, which uses
|
||||
/// the [`History API`](https://developer.mozilla.org/en-US/docs/Web/API/History).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct BrowserIntegration {}
|
||||
|
||||
@@ -66,7 +73,7 @@ impl History for BrowserIntegration {
|
||||
) {
|
||||
log::error!("{e:#?}");
|
||||
}
|
||||
set_location(Self::current());
|
||||
set_location.set(Self::current());
|
||||
} else {
|
||||
log::warn!("RouterContext not found");
|
||||
}
|
||||
@@ -105,10 +112,23 @@ impl History for BrowserIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapper type that the [Router](crate::Router) uses to interact with a [History].
|
||||
/// This is automatically provided in the browser. For the server, it should be provided
|
||||
/// as a context.
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_router::*;
|
||||
/// # use leptos::*;
|
||||
/// # run_scope(|cx| {
|
||||
/// let integration = ServerIntegration { path: "insert/current/path/here".to_string() };
|
||||
/// provide_context(cx, RouterIntegrationContext::new(integration));
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct RouterIntegrationContext(pub Rc<dyn History>);
|
||||
|
||||
impl RouterIntegrationContext {
|
||||
/// Creates a new router integration.
|
||||
pub fn new(history: impl History + 'static) -> Self {
|
||||
Self(Rc::new(history))
|
||||
}
|
||||
@@ -124,6 +144,7 @@ impl History for RouterIntegrationContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic router integration for the server side. All its need is the current path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerIntegration {
|
||||
pub path: String,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
|
||||
use linear_map::LinearMap;
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::RouterError;
|
||||
|
||||
/// A key-value map of the current named route params and their values.
|
||||
// For now, implemented with a `LinearMap`, as `n` is small enough
|
||||
// that O(n) iteration over a vectorized map is (*probably*) more space-
|
||||
// and time-efficient than hashing and using an actual `HashMap`
|
||||
@@ -11,27 +10,33 @@ use crate::RouterError;
|
||||
pub struct ParamsMap(pub LinearMap<String, String>);
|
||||
|
||||
impl ParamsMap {
|
||||
/// Creates an empty map.
|
||||
pub fn new() -> Self {
|
||||
Self(LinearMap::new())
|
||||
}
|
||||
|
||||
/// Creates an empty map with the given capacity.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self(LinearMap::with_capacity(capacity))
|
||||
}
|
||||
|
||||
/// Inserts a value into the map.
|
||||
pub fn insert(&mut self, key: String, value: String) -> Option<String> {
|
||||
self.0.insert(key, value)
|
||||
}
|
||||
|
||||
/// Gets a value from the map.
|
||||
pub fn get(&self, key: &str) -> Option<&String> {
|
||||
self.0.get(key)
|
||||
}
|
||||
|
||||
/// Removes a value from the map.
|
||||
pub fn remove(&mut self, key: &str) -> Option<String> {
|
||||
self.0.remove(key)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
|
||||
/// Converts the map to a query string.
|
||||
pub fn to_query_string(&self) -> String {
|
||||
use crate::history::url::escape;
|
||||
let mut buf = String::from("?");
|
||||
@@ -51,6 +56,16 @@ impl Default for ParamsMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// A declarative way of creating a [ParamsMap].
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_router::params_map;
|
||||
/// let map = params_map! {
|
||||
/// "id".to_string() => "1".to_string()
|
||||
/// };
|
||||
/// assert_eq!(map.get("id"), Some(&"1".to_string()));
|
||||
/// assert_eq!(map.get("missing"), None)
|
||||
/// ```
|
||||
// Adapted from hash_map! in common_macros crate
|
||||
// Copyright (c) 2019 Philipp Korber
|
||||
// https://github.com/rustonaut/common_macros/blob/master/src/lib.rs
|
||||
@@ -68,15 +83,19 @@ macro_rules! params_map {
|
||||
});
|
||||
}
|
||||
|
||||
/// A simple method of deserializing key-value data (like route params or URL search)
|
||||
/// into a concrete data type. `Self` should typically be a struct in which
|
||||
/// each field's type implements [FromStr].
|
||||
pub trait Params
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
fn from_map(map: &ParamsMap) -> Result<Self, RouterError>;
|
||||
/// Attempts to deserialize the map into the given type.
|
||||
fn from_map(map: &ParamsMap) -> Result<Self, ParamsError>;
|
||||
}
|
||||
|
||||
impl Params for () {
|
||||
fn from_map(_map: &ParamsMap) -> Result<Self, RouterError> {
|
||||
fn from_map(_map: &ParamsMap) -> Result<Self, ParamsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -85,22 +104,22 @@ pub trait IntoParam
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError>;
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError>;
|
||||
}
|
||||
|
||||
impl<T> IntoParam for Option<T>
|
||||
where
|
||||
T: FromStr,
|
||||
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
|
||||
<T as FromStr>::Err: std::error::Error + 'static,
|
||||
{
|
||||
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, RouterError> {
|
||||
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, ParamsError> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(value) => match T::from_str(value) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
Err(RouterError::Params(Rc::new(e)))
|
||||
Err(ParamsError::Params(Rc::new(e)))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -115,8 +134,29 @@ where
|
||||
T: FromStr + NotOption,
|
||||
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError> {
|
||||
let value = value.ok_or_else(|| RouterError::MissingParam(name.to_string()))?;
|
||||
Self::from_str(value).map_err(|e| RouterError::Params(Rc::new(e)))
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError> {
|
||||
let value = value.ok_or_else(|| ParamsError::MissingParam(name.to_string()))?;
|
||||
Self::from_str(value).map_err(|e| ParamsError::Params(Rc::new(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur while parsing params using [IntoParams].
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum ParamsError {
|
||||
/// A field was missing from the route params.
|
||||
#[error("could not find parameter {0}")]
|
||||
MissingParam(String),
|
||||
/// Something went wrong while deserializing a field.
|
||||
#[error("failed to deserialize parameters")]
|
||||
Params(Rc<dyn std::error::Error>),
|
||||
}
|
||||
|
||||
impl PartialEq for ParamsError {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
|
||||
(Self::Params(_), Self::Params(_)) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ use std::rc::Rc;
|
||||
use leptos::{create_memo, use_context, Memo, Scope};
|
||||
|
||||
use crate::{
|
||||
Location, NavigateOptions, NavigationError, Params, ParamsMap, RouteContext, RouterContext,
|
||||
RouterError,
|
||||
Location, NavigateOptions, NavigationError, Params, ParamsError, ParamsMap, RouteContext,
|
||||
RouterContext,
|
||||
};
|
||||
|
||||
/// Returns the current [RouterContext], containing information about the router's state.
|
||||
pub fn use_router(cx: Scope) -> RouterContext {
|
||||
if let Some(router) = use_context::<RouterContext>(cx) {
|
||||
router
|
||||
@@ -16,15 +17,24 @@ pub fn use_router(cx: Scope) -> RouterContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current [RouteContext], containing information about the matched route.
|
||||
pub fn use_route(cx: Scope) -> RouteContext {
|
||||
use_context::<RouteContext>(cx).unwrap_or_else(|| use_router(cx).base())
|
||||
}
|
||||
|
||||
/// Returns the current [Location], which contains reactive variables
|
||||
pub fn use_location(cx: Scope) -> Location {
|
||||
use_router(cx).inner.location.clone()
|
||||
}
|
||||
|
||||
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
|
||||
/// Returns a raw key-value map of route params.
|
||||
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
let route = use_route(cx);
|
||||
route.params()
|
||||
}
|
||||
|
||||
/// Returns the current route params, parsed into the given type, or an error.
|
||||
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
|
||||
where
|
||||
T: PartialEq + std::fmt::Debug,
|
||||
{
|
||||
@@ -32,12 +42,13 @@ where
|
||||
create_memo(cx, move |_| route.params().with(T::from_map))
|
||||
}
|
||||
|
||||
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
let route = use_route(cx);
|
||||
route.params()
|
||||
/// Returns a raw key-value map of the URL search query.
|
||||
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
use_router(cx).inner.location.query
|
||||
}
|
||||
|
||||
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
|
||||
/// Returns the current URL search query, parsed into the given type, or an error.
|
||||
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
|
||||
where
|
||||
T: PartialEq + std::fmt::Debug,
|
||||
{
|
||||
@@ -47,16 +58,14 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
use_router(cx).inner.location.query
|
||||
}
|
||||
|
||||
/// Resolves the given path relative to the current route.
|
||||
pub fn use_resolved_path(cx: Scope, path: impl Fn() -> String + 'static) -> Memo<Option<String>> {
|
||||
let route = use_route(cx);
|
||||
|
||||
create_memo(cx, move |_| route.resolve_path(&path()).map(String::from))
|
||||
}
|
||||
|
||||
/// Returns a function that can be used to navigate to a new route.
|
||||
pub fn use_navigate(cx: Scope) -> impl Fn(&str, NavigateOptions) -> Result<(), NavigationError> {
|
||||
let router = use_router(cx);
|
||||
move |to, options| Rc::clone(&router.inner).navigate_from_route(to, &options)
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! apps (SPAs), server-side rendering/multi-page apps (MPAs), or to synchronize
|
||||
//! state between the two.
|
||||
//!
|
||||
//! **Note:** This is a work in progress. Docs are still being written,
|
||||
//! and some features are only stubs, in particular
|
||||
//! - passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
|
||||
//! - data mutations using [Action]s and [Form] `method="POST"`
|
||||
//! **Note:** This is a work in progress. Docs are still being written, in particular
|
||||
//! passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
|
||||
//!
|
||||
//! ## Philosophy
|
||||
//!
|
||||
@@ -62,20 +60,13 @@
|
||||
//! <Route
|
||||
//! path=""
|
||||
//! element=move |cx| view! { cx, <ContactList/> }
|
||||
//! // <ContactList/> needs all the contacts, so we provide the loader here
|
||||
//! // this will only be reloaded if we navigate away to /about and back to / or /:id
|
||||
//! loader=contact_list_data.into()
|
||||
//! >
|
||||
//! // users like /gbj or /bob
|
||||
//! <Route
|
||||
//! path=":id"
|
||||
//! // <Contact/> needs contact data, so we provide the loader here
|
||||
//! // this will be reloaded when the :id changes
|
||||
//! loader=contact_data.into()
|
||||
//! element=move |cx| view! { cx, <Contact/> }
|
||||
//! />
|
||||
//! // a fallback if the /:id segment is missing from the URL
|
||||
//! // doesn't need any data, so no loader is provided
|
||||
//! <Route
|
||||
//! path=""
|
||||
//! element=move |_| view! { cx, <p class="contact">"Select a contact."</p> }
|
||||
@@ -93,33 +84,46 @@
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Loaders are async functions that have access to the reactive scope,
|
||||
//! // map of matched URL params for that route, and the URL
|
||||
//! // They are reloaded whenever the params or URL change
|
||||
//!
|
||||
//! type ContactSummary = (); // TODO!
|
||||
//! type Contact = (); // TODO!()
|
||||
//!
|
||||
//! // contact_data reruns whenever the :id param changes
|
||||
//! async fn contact_data(_cx: Scope, _params: ParamsMap, url: Url) -> Contact {
|
||||
//! async fn contact_data(id: String) -> Contact {
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
//! // contact_list_data *doesn't* rerun when the :id changes,
|
||||
//! // because that param is nested lower than the <ContactList/> route
|
||||
//! async fn contact_list_data(_cx: Scope, _params: ParamsMap, url: Url) -> Vec<ContactSummary> {
|
||||
//! async fn contact_list_data() -> Vec<ContactSummary> {
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
//! #[component]
|
||||
//! fn ContactList(cx: Scope) -> Element {
|
||||
//! let data = use_loader::<Vec<ContactSummary>>(cx);
|
||||
//! todo!()
|
||||
//! // loads the contact list data once; doesn't reload when nested routes change
|
||||
//! let contacts = create_resource(cx, || (), |_| contact_list_data());
|
||||
//! view! {
|
||||
//! cx,
|
||||
//! <div>
|
||||
//! // show the contacts
|
||||
//! <ul>
|
||||
//! {move || contacts.read().map(|contacts| view! { cx, <li>"todo contact info"</li> } )}
|
||||
//! </ul>
|
||||
//!
|
||||
//! // insert the nested child route here
|
||||
//! <Outlet/>
|
||||
//! </div>
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! #[component]
|
||||
//! fn Contact(cx: Scope) -> Element {
|
||||
//! let data = use_loader::<Contact>(cx);
|
||||
//! let params = use_params_map(cx);
|
||||
//! let data = create_resource(
|
||||
//! cx,
|
||||
//! move || params.with(|p| p.get("id").cloned().unwrap_or_default()),
|
||||
//! move |id| contact_data(id)
|
||||
//! );
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
@@ -136,16 +140,12 @@
|
||||
#![feature(type_name_of_val)]
|
||||
|
||||
mod components;
|
||||
mod data;
|
||||
mod error;
|
||||
mod fetch;
|
||||
mod history;
|
||||
mod hooks;
|
||||
mod matching;
|
||||
|
||||
pub use components::*;
|
||||
pub use data::*;
|
||||
pub use error::*;
|
||||
pub use fetch::*;
|
||||
pub use history::*;
|
||||
pub use hooks::*;
|
||||
|
||||
@@ -25,9 +25,12 @@ pub(crate) fn get_route_matches(branches: Vec<Branch>, location: String) -> Vec<
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Describes a branch of the route tree.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Branch {
|
||||
/// All the routes contained in the branch.
|
||||
pub routes: Vec<RouteData>,
|
||||
/// How closely this branch matches the current URL.
|
||||
pub score: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,9 @@ use std::rc::Rc;
|
||||
use leptos::leptos_dom::Child;
|
||||
use leptos::*;
|
||||
|
||||
use crate::{Action, Loader};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RouteDefinition {
|
||||
pub path: &'static str,
|
||||
pub loader: Option<Loader>,
|
||||
pub action: Option<Action>,
|
||||
pub children: Vec<RouteDefinition>,
|
||||
pub element: Rc<dyn Fn(Scope) -> Child>,
|
||||
}
|
||||
@@ -18,8 +14,6 @@ impl std::fmt::Debug for RouteDefinition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RouteDefinition")
|
||||
.field("path", &self.path)
|
||||
.field("loader", &self.loader)
|
||||
.field("action", &self.action)
|
||||
.field("children", &self.children)
|
||||
.finish()
|
||||
}
|
||||
@@ -35,8 +29,6 @@ impl Default for RouteDefinition {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: Default::default(),
|
||||
loader: Default::default(),
|
||||
action: Default::default(),
|
||||
children: Default::default(),
|
||||
element: Rc::new(|_| Child::Null),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user