mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 16:54:41 -05:00
Compare commits
129 Commits
actix_midd
...
v0.6.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e93a686f4 | ||
|
|
34cdff4cb3 | ||
|
|
530087d77d | ||
|
|
4bb43f6207 | ||
|
|
9e2fb62857 | ||
|
|
1da2fff706 | ||
|
|
9fd2987447 | ||
|
|
7996f835d0 | ||
|
|
d72b12524e | ||
|
|
8e79c5be5c | ||
|
|
de25658c36 | ||
|
|
e2e35a9659 | ||
|
|
bf1ba589c5 | ||
|
|
f70ebc1289 | ||
|
|
3cab09e015 | ||
|
|
b431315f7c | ||
|
|
5b40881e77 | ||
|
|
59d3cce3be | ||
|
|
6a83161368 | ||
|
|
4b00c16cb9 | ||
|
|
6d6019b956 | ||
|
|
3540291065 | ||
|
|
4809cf473e | ||
|
|
aa977001c1 | ||
|
|
c16189f095 | ||
|
|
d0a013c248 | ||
|
|
531ea74e33 | ||
|
|
545e87e540 | ||
|
|
2ca30a0b2d | ||
|
|
753bf1ed54 | ||
|
|
37c6387fea | ||
|
|
0a73487152 | ||
|
|
747aba0d7f | ||
|
|
04cf47d5da | ||
|
|
47abe00993 | ||
|
|
aa3700ffb9 | ||
|
|
0770b87cb7 | ||
|
|
330ebdb018 | ||
|
|
c3179d88cf | ||
|
|
ffcf3c2952 | ||
|
|
001ca5148e | ||
|
|
1e000afa78 | ||
|
|
0f7b8841b2 | ||
|
|
7dc0441f6c | ||
|
|
0a321a1bd7 | ||
|
|
88742952f0 | ||
|
|
8a4b972e0b | ||
|
|
95bd9cc544 | ||
|
|
23bc892a24 | ||
|
|
830fba794e | ||
|
|
98633c8700 | ||
|
|
b0f5c39711 | ||
|
|
b54aa7f3f5 | ||
|
|
e33ee7ec99 | ||
|
|
cd70b2f52b | ||
|
|
c75842ed0c | ||
|
|
4ad228bf47 | ||
|
|
bbe7115360 | ||
|
|
0658a550b0 | ||
|
|
4222c832b1 | ||
|
|
dfddbd6bf9 | ||
|
|
8a77691cb5 | ||
|
|
1dbe8b2d4b | ||
|
|
fe64f0d332 | ||
|
|
c00207aa46 | ||
|
|
65b7603192 | ||
|
|
d4bdc36062 | ||
|
|
1b55227d10 | ||
|
|
a903e19eb2 | ||
|
|
38bf73947f | ||
|
|
e4b89ba243 | ||
|
|
701e3077fb | ||
|
|
aec4d680aa | ||
|
|
06721c5fcd | ||
|
|
1ddb39e9bd | ||
|
|
15d4ca0638 | ||
|
|
85c3755f6d | ||
|
|
66ea072bc0 | ||
|
|
b0b3c21285 | ||
|
|
56088a9ead | ||
|
|
69d25d9c63 | ||
|
|
5029b8f315 | ||
|
|
0cba7bc22b | ||
|
|
fb97c50886 | ||
|
|
f1bc734dcf | ||
|
|
f71b4aae69 | ||
|
|
a834c03974 | ||
|
|
595013579c | ||
|
|
8b1bd1ae9e | ||
|
|
6ef1531059 | ||
|
|
9f1406250e | ||
|
|
1f6a892291 | ||
|
|
0ff1e279a2 | ||
|
|
c6096cc2a0 | ||
|
|
8a2ae7fc7c | ||
|
|
9de34b74cf | ||
|
|
1b5961edaa | ||
|
|
26d1aee9ad | ||
|
|
2bf09384df | ||
|
|
ac12e1a411 | ||
|
|
b367b68a43 | ||
|
|
1f9dad421f | ||
|
|
4648fb2cfc | ||
|
|
817ec045f7 | ||
|
|
ca3806e6bc | ||
|
|
936c2077c3 | ||
|
|
b3b18875c6 | ||
|
|
5cbab48713 | ||
|
|
5a8880dd2e | ||
|
|
ea6c957f3d | ||
|
|
694e5f1cb3 | ||
|
|
fce2c727ab | ||
|
|
7d1ce45a57 | ||
|
|
997b99081b | ||
|
|
d33e57d4b7 | ||
|
|
b450f0fd10 | ||
|
|
c84c6ee8cd | ||
|
|
567644df8f | ||
|
|
39f5481b8c | ||
|
|
c88bfbe0a0 | ||
|
|
40da1fe94e | ||
|
|
8df46fcdb9 | ||
|
|
b4a1d90327 | ||
|
|
d746f83387 | ||
|
|
2092c40bc7 | ||
|
|
70ec0c2d0a | ||
|
|
eb45d05f3b | ||
|
|
f19def9541 | ||
|
|
ddda785045 |
2
.github/workflows/ci-changed-examples.yml
vendored
2
.github/workflows/ci-changed-examples.yml
vendored
@@ -29,4 +29,4 @@ jobs:
|
||||
with:
|
||||
directory: ${{ matrix.directory }}
|
||||
cargo_make_task: "ci"
|
||||
toolchain: nightly
|
||||
toolchain: nightly-2024-01-29
|
||||
|
||||
2
.github/workflows/ci-examples.yml
vendored
2
.github/workflows/ci-examples.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
with:
|
||||
directory: ${{ matrix.directory }}
|
||||
cargo_make_task: "ci"
|
||||
toolchain: nightly
|
||||
toolchain: nightly-2024-01-29
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -40,4 +40,4 @@ jobs:
|
||||
with:
|
||||
directory: ${{ matrix.directory }}
|
||||
cargo_make_task: "ci"
|
||||
toolchain: nightly
|
||||
toolchain: nightly-2024-01-29
|
||||
|
||||
@@ -31,10 +31,9 @@ jobs:
|
||||
dir_names: true
|
||||
dir_names_max_depth: "2"
|
||||
files: |
|
||||
examples
|
||||
!examples/cargo-make
|
||||
!examples/gtk
|
||||
!examples/hackernews_js_fetch
|
||||
examples/**
|
||||
!examples/cargo-make/**
|
||||
!examples/gtk/**
|
||||
!examples/Makefile.toml
|
||||
!examples/*.md
|
||||
json: true
|
||||
|
||||
4
.github/workflows/get-example-changed.yml
vendored
4
.github/workflows/get-example-changed.yml
vendored
@@ -25,8 +25,8 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
examples/**
|
||||
!examples/cargo-make
|
||||
!examples/gtk
|
||||
!examples/cargo-make/**
|
||||
!examples/gtk/**
|
||||
!examples/Makefile.toml
|
||||
!examples/*.md
|
||||
|
||||
|
||||
9
.github/workflows/run-cargo-make-task.yml
vendored
9
.github/workflows/run-cargo-make-task.yml
vendored
@@ -55,9 +55,9 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
@@ -107,6 +107,11 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Install Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
# Run Cargo Make Task
|
||||
- name: ${{ inputs.cargo_make_task }}
|
||||
run: |
|
||||
|
||||
27
Cargo.toml
27
Cargo.toml
@@ -25,22 +25,23 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0-beta"
|
||||
version = "0.6.9"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", version = "0.6.0-beta" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.6.0-beta" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.0-beta" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.6.0-beta" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.6.0-beta" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.6.0-beta" }
|
||||
server_fn = { path = "./server_fn", version = "0.6.0-beta" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.6.0-beta" }
|
||||
leptos = { path = "./leptos", version = "0.6.9" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.6.9" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.9" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.6.9" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.6.9" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.6.9" }
|
||||
server_fn = { path = "./server_fn", version = "0.6.9" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.6.9" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.6" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.6.0-beta" }
|
||||
leptos_router = { path = "./router", version = "0.6.0-beta" }
|
||||
leptos_meta = { path = "./meta", version = "0.6.0-beta" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.0-beta" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.6.9" }
|
||||
leptos_router = { path = "./router", version = "0.6.9" }
|
||||
leptos_meta = { path = "./meta", version = "0.6.9" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.9" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
59
README.md
59
README.md
@@ -40,6 +40,25 @@ pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
// we also support a builder syntax rather than the JSX-like `view` macro
|
||||
#[component]
|
||||
pub fn SimpleCounterWithBuilder(initial_value: i32) -> impl IntoView {
|
||||
use leptos::html::*;
|
||||
|
||||
let (value, set_value) = create_signal(initial_value);
|
||||
let clear = move |_| set_value(0);
|
||||
let decrement = move |_| set_value.update(|value| *value -= 1);
|
||||
let increment = move |_| set_value.update(|value| *value += 1);
|
||||
|
||||
// the `view` macro above expands to this builder syntax
|
||||
div().child((
|
||||
button().on(ev::click, clear).child("Clear"),
|
||||
button().on(ev::click, decrement).child("-1"),
|
||||
span().child(("Value: ", value, "!")),
|
||||
button().on(ev::click, increment).child("+1")
|
||||
))
|
||||
}
|
||||
|
||||
// Easy to use with Trunk (trunkrs.dev) or with a simple wasm-bindgen setup
|
||||
pub fn main() {
|
||||
mount_to_body(|| view! {
|
||||
@@ -131,7 +150,7 @@ There are several people in the community using Leptos right now for internal ap
|
||||
|
||||
### Can I use this for native GUI?
|
||||
|
||||
Sure! Obviously the `view` macro is for generating DOM nodes but you can use the reactive system to drive native any GUI toolkit that uses the same kind of object-oriented, event-callback-based framework as the DOM pretty easily. The principles are the same:
|
||||
Sure! Obviously the `view` macro is for generating DOM nodes but you can use the reactive system to drive any native GUI toolkit that uses the same kind of object-oriented, event-callback-based framework as the DOM pretty easily. The principles are the same:
|
||||
|
||||
- Use signals, derived signals, and memos to create your reactive system
|
||||
- Create GUI widgets
|
||||
@@ -140,35 +159,27 @@ Sure! Obviously the `view` macro is for generating DOM nodes but you can use the
|
||||
|
||||
I've put together a [very simple GTK example](https://github.com/leptos-rs/leptos/blob/main/examples/gtk/src/main.rs) so you can see what I mean.
|
||||
|
||||
### How is this different from Yew/Dioxus?
|
||||
The new rendering approach being developed for 0.7 supports “universal rendering,” i.e., it can use any rendering library that supports a small set of 6-8 functions. (This is intended as a layer over typical retained-mode, OOP-style GUI toolkits like the DOM, GTK, etc.) That future rendering work will allow creating native UI in a way that is much more similar to the declarative approach used by the web framework.
|
||||
|
||||
On the surface level, these libraries may seem similar. Yew is, of course, the most mature Rust library for web UI development and has a huge ecosystem. Dioxus is similar in many ways, being heavily inspired by React. Here are some conceptual differences between Leptos and these frameworks:
|
||||
### How is this different from Yew?
|
||||
|
||||
Yew is the most-used library for Rust web UI development, but there are several differences between Yew and Leptos, in philosophy, approach, and performance.
|
||||
|
||||
- **VDOM vs. fine-grained:** Yew is built on the virtual DOM (VDOM) model: state changes cause components to re-render, generating a new virtual DOM tree. Yew diffs this against the previous VDOM, and applies those patches to the actual DOM. Component functions rerun whenever state changes. Leptos takes an entirely different approach. Components run once, creating (and returning) actual DOM nodes and setting up a reactive system to update those DOM nodes.
|
||||
- **Performance:** This has huge performance implications: Leptos is simply much faster at both creating and updating the UI than Yew is. (Dioxus has made huge advances in performance with its recent 0.3 release, and is now roughly on par with Leptos.)
|
||||
- **Mental model:** Adopting fine-grained reactivity also tends to simplify the mental model. There are no surprising component re-renders because there are no re-renders. You can call functions, create timeouts, etc. within the body of your component functions because they won’t be re-run. You don’t need to think about manual dependency tracking for effects; fine-grained reactivity tracks dependencies automatically.
|
||||
- **Performance:** This has huge performance implications: Leptos is simply much faster at both creating and updating the UI than Yew is.
|
||||
- **Server integration:** Yew was created in an era in which browser-rendered single-page apps (SPAs) were the dominant paradigm. While Leptos supports client-side rendering, it also focuses on integrating with the server side of your application via server functions and multiple modes of serving HTML, including out-of-order streaming.
|
||||
|
||||
### How is this different from Sycamore?
|
||||
- ### How is this different from Dioxus?
|
||||
|
||||
Conceptually, these two frameworks are very similar: because both are built on fine-grained reactivity, most apps will end up looking very similar between the two, and Sycamore or Leptos apps will both look a lot like SolidJS apps, in the same way that Yew or Dioxus can look a lot like React.
|
||||
Like Leptos, Dioxus is a framework for building UIs using web technologies. However, there are significant differences in approach and features.
|
||||
|
||||
There are some practical differences that make a significant difference:
|
||||
- **VDOM vs. fine-grained:** While Dioxus has a performant virtual DOM (VDOM), it still uses coarse-grained/component-scoped reactivity: changing a stateful value reruns the component function and diffs the old UI against the new one. Leptos components use a different mental model, creating (and returning) actual DOM nodes and setting up a reactive system to update those DOM nodes.
|
||||
- **Web vs. desktop priorities:** Dioxus uses Leptos server functions in its fullstack mode, but does not have the same `<Suspense>`-based support for things like streaming HTML rendering, or share the same focus on holistic web performance. Leptos tends to prioritize holistic web performance (streaming HTML rendering, smaller WASM binary sizes, etc.), whereas Dioxus has an unparalleled experience when building desktop apps, because your application logic runs as a native Rust binary.
|
||||
|
||||
- **Templating:** Leptos uses a JSX-like template format (built on [syn-rsx](https://github.com/stoically/syn-rsx)) for its `view` macro. Sycamore offers the choice of its own templating DSL or a builder syntax.
|
||||
- **Server integration:** Leptos provides primitives that encourage HTML streaming and allow for easy async integration and RPC calls, even without WASM enabled, making it easy to opt into integrations between your frontend and backend code without pushing you toward any particular metaframework patterns.
|
||||
- **Read-write segregation:** Leptos, like Solid, encourages read-write segregation between signal getters and setters, so you end up accessing signals with tuples like `let (count, set_count) = create_signal(0);` _(If you prefer or if it's more convenient for your API, you can use [`create_rw_signal`](https://docs.rs/leptos/latest/leptos/fn.create_rw_signal.html) to give a unified read/write signal.)_
|
||||
- **Signals are functions:** In Leptos, you can call a signal to access it rather than calling a specific method (so, `count()` instead of `count.get()`) This creates a more consistent mental model: accessing a reactive value is always a matter of calling a function. For example:
|
||||
- ### How is this different from Sycamore?
|
||||
|
||||
```rust
|
||||
let (count, set_count) = create_signal(0); // a signal
|
||||
let double_count = move || count() * 2; // a derived signal
|
||||
let memoized_count = create_memo(move |_| count() * 3); // a memo
|
||||
// all are accessed by calling them
|
||||
assert_eq!(count(), 0);
|
||||
assert_eq!(double_count(), 0);
|
||||
assert_eq!(memoized_count(), 0);
|
||||
// this function can accept any of those signals
|
||||
fn do_work_on_signal(my_signal: impl Fn() -> i32) { ... }
|
||||
```
|
||||
Sycamore and Leptos are both heavily influenced by SolidJS. At this point, Leptos has a larger community and ecosystem and is more actively developed. Other differences:
|
||||
|
||||
- **Signals and scopes are `'static`:** Both Leptos and Sycamore ease the pain of moving signals in closures (in particular, event listeners) by making them `Copy`, to avoid the `{ let count = count.clone(); move |_| ... }` that's very familiar in Rust UI code. Sycamore does this by using bump allocation to tie the lifetimes of its signals to its scopes: since references are `Copy`, `&'a Signal<T>` can be moved into a closure. Leptos does this by using arena allocation and passing around indices: types like `ReadSignal<T>`, `WriteSignal<T>`, and `Memo<T>` are actually wrappers for indices into an arena. This means that both scopes and signals are both `Copy` and `'static` in Leptos, which means that they can be moved easily into closures without adding lifetime complexity.
|
||||
- **Templating DSLs:** Sycamore uses a custom templating language for its views, while Leptos uses a JSX-like template format.
|
||||
- **`'static` signals:** One of Leptos’s main innovations was the creation of `Copy + 'static` signals, which have excellent ergonomics. Sycamore is in the process of adopting the same pattern, but this is not yet released.
|
||||
- **Perseus vs. server functions:** The Perseus metaframework provides an opinionated way to build Sycamore apps that include server functionality. Leptos instead provides primitives like server functions in the core of the framework.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "benchmarks"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
l0410 = { package = "leptos", version = "0.4.10", features = [
|
||||
|
||||
@@ -3,5 +3,5 @@ alias = "check-all"
|
||||
|
||||
[tasks.check-all]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
args = ["+nightly-2024-01-29", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
@@ -3,5 +3,5 @@ alias = "test-all"
|
||||
|
||||
[tasks.test-all]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "test-all-features"]
|
||||
args = ["+nightly-2024-01-29", "test-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
@@ -51,103 +51,5 @@ echo "CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = $examples"
|
||||
|
||||
[tasks.test-report]
|
||||
workspace = false
|
||||
description = "report web testing technology used by examples - OPTION: [all]"
|
||||
script = '''
|
||||
set -emu
|
||||
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
ITALIC="\e[3m"
|
||||
YELLOW="\e[0;33m"
|
||||
RESET="\e[0m"
|
||||
|
||||
echo
|
||||
echo "${YELLOW}Web Test Technology${RESET}"
|
||||
echo
|
||||
|
||||
makefile_paths=$(find . -name Makefile.toml -not -path '*/target/*' -not -path '*/node_modules/*' |
|
||||
sed 's%./%%' |
|
||||
sed 's%/Makefile.toml%%' |
|
||||
grep -v Makefile.toml |
|
||||
sort -u)
|
||||
|
||||
start_path=$(pwd)
|
||||
|
||||
for path in $makefile_paths; do
|
||||
cd $path
|
||||
|
||||
crate_symbols=
|
||||
|
||||
pw_count=$(find . -name playwright.config.ts | wc -l)
|
||||
|
||||
while read -r line; do
|
||||
case $line in
|
||||
*"cucumber"*)
|
||||
crate_symbols=$crate_symbols"C"
|
||||
;;
|
||||
*"fantoccini"*)
|
||||
crate_symbols=$crate_symbols"D"
|
||||
;;
|
||||
esac
|
||||
done <"./Cargo.toml"
|
||||
|
||||
while read -r line; do
|
||||
case $line in
|
||||
*"cargo-make/wasm-test.toml"*)
|
||||
crate_symbols=$crate_symbols"W"
|
||||
;;
|
||||
*"cargo-make/playwright-test.toml"*)
|
||||
crate_symbols=$crate_symbols"P"
|
||||
crate_symbols=$crate_symbols"N"
|
||||
;;
|
||||
*"cargo-make/playwright-trunk-test.toml"*)
|
||||
crate_symbols=$crate_symbols"P"
|
||||
crate_symbols=$crate_symbols"T"
|
||||
;;
|
||||
*"cargo-make/trunk_server.toml"*)
|
||||
crate_symbols=$crate_symbols"T"
|
||||
;;
|
||||
*"cargo-make/cargo-leptos-webdriver-test.toml"*)
|
||||
crate_symbols=$crate_symbols"L"
|
||||
;;
|
||||
*"cargo-make/cargo-leptos-test.toml"*)
|
||||
crate_symbols=$crate_symbols"L"
|
||||
if [ $pw_count -gt 0 ]; then
|
||||
crate_symbols=$crate_symbols"P"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done <"./Makefile.toml"
|
||||
|
||||
# Sort list of tools
|
||||
sorted_crate_symbols=$(echo ${crate_symbols} | grep -o . | sort | tr -d "\n")
|
||||
|
||||
formatted_crate_symbols=" ➤ ${BOLD}${YELLOW}${sorted_crate_symbols}${RESET}"
|
||||
crate_line=$path
|
||||
if [ ! -z ${1+x} ]; then
|
||||
# Show all examples
|
||||
if [ ! -z $crate_symbols ]; then
|
||||
crate_line=$crate_line$formatted_crate_symbols
|
||||
fi
|
||||
echo $crate_line
|
||||
elif [ ! -z $crate_symbols ]; then
|
||||
# Filter out examples that do not run tests in `ci`
|
||||
crate_line=$crate_line$formatted_crate_symbols
|
||||
echo $crate_line
|
||||
fi
|
||||
|
||||
cd ${start_path}
|
||||
done
|
||||
|
||||
c="${BOLD}${YELLOW}C${RESET} = Cucumber"
|
||||
d="${BOLD}${YELLOW}D${RESET} = WebDriver"
|
||||
l="${BOLD}${YELLOW}L${RESET} = Cargo Leptos"
|
||||
n="${BOLD}${YELLOW}N${RESET} = Node"
|
||||
p="${BOLD}${YELLOW}P${RESET} = Playwright"
|
||||
t="${BOLD}${YELLOW}T${RESET} = Trunk"
|
||||
w="${BOLD}${YELLOW}W${RESET} = WASM"
|
||||
|
||||
echo
|
||||
echo "${ITALIC}Keys:${RESET} $c, $d, $l, $n, $p, $t, $w"
|
||||
echo
|
||||
'''
|
||||
description = "show the cargo-make configuration for web examples [web|all|help]"
|
||||
script = { file = "./cargo-make/scripts/web-report.sh" }
|
||||
|
||||
@@ -16,7 +16,7 @@ You can also run any of the examples using [`cargo-make`](https://github.com/sag
|
||||
|
||||
Follow these steps to get any example up and running.
|
||||
|
||||
1. `cd` to the example root directory
|
||||
1. `cd` to the example you want to run
|
||||
2. Run `cargo make ci` to setup and test the example
|
||||
3. Run `cargo make start` to run the example
|
||||
4. Open the client URL in the console output (<http://127.0.0.1:8080> or <http://127.0.0.1:3000> by default)
|
||||
|
||||
@@ -15,13 +15,13 @@ clear = true
|
||||
dependencies = ["check-debug", "check-release"]
|
||||
|
||||
[tasks.check-debug]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check-release]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--release"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
@@ -3,32 +3,36 @@
|
||||
[tasks.stop-client]
|
||||
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ ! -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
|
||||
if pidof -q ${CLIENT_PROCESS_NAME}; then
|
||||
echo " Stopping ${CLIENT_PROCESS_NAME}"
|
||||
pkill -ef ${CLIENT_PROCESS_NAME}
|
||||
else
|
||||
echo " ${CLIENT_PROCESS_NAME} is already stopped"
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.client-status]
|
||||
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
|
||||
echo " ${CLIENT_PROCESS_NAME} is not running"
|
||||
else
|
||||
if pidof -q ${CLIENT_PROCESS_NAME}; then
|
||||
echo " ${CLIENT_PROCESS_NAME} is up"
|
||||
else
|
||||
echo " ${CLIENT_PROCESS_NAME} is not running"
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.maybe-start-client]
|
||||
condition = { env_set = ["CLIENT_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
|
||||
if pidof -q ${CLIENT_PROCESS_NAME}; then
|
||||
echo " ${CLIENT_PROCESS_NAME} is already started"
|
||||
else
|
||||
echo " Starting ${CLIENT_PROCESS_NAME}"
|
||||
if [ -z ${SPAWN_CLIENT_PROCESS} ];then
|
||||
if [ -n "${SPAWN_CLIENT_PROCESS}" ];then
|
||||
echo "Spawning process..."
|
||||
cargo make start-client ${@} &
|
||||
else
|
||||
cargo make start-client ${@}
|
||||
fi
|
||||
else
|
||||
echo " ${CLIENT_PROCESS_NAME} is already started"
|
||||
fi
|
||||
'''
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[tasks.build]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
24
examples/cargo-make/deno-build.toml
Normal file
24
examples/cargo-make/deno-build.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[tasks.build]
|
||||
clear = true
|
||||
command = "deno"
|
||||
args = ["task", "build"]
|
||||
|
||||
[tasks.start-client]
|
||||
command = "deno"
|
||||
args = ["task", "start"]
|
||||
|
||||
[tasks.check]
|
||||
clear = true
|
||||
dependencies = ["check-debug", "check-release"]
|
||||
|
||||
[tasks.check-debug]
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check-release]
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--release"]
|
||||
install_crate = "cargo-all-features"
|
||||
@@ -1,5 +1,5 @@
|
||||
[tasks.pre-clippy]
|
||||
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
|
||||
env = { CARGO_MAKE_CLIPPY_ARGS = "--no-deps --all-targets --all-features -- -D warnings" }
|
||||
|
||||
[tasks.check-style]
|
||||
dependencies = ["check-format-flow", "clippy-flow"]
|
||||
|
||||
@@ -6,9 +6,17 @@ extend = [
|
||||
[tasks.integration-test]
|
||||
description = "Run integration test with automated start and stop of processes"
|
||||
env = { SPAWN_CLIENT_PROCESS = "1" }
|
||||
dependencies = ["start", "wait-one", "test-playwright", "stop"]
|
||||
run_task = { name = ["start", "wait-test-stop"], parallel = true }
|
||||
|
||||
[tasks.wait-one]
|
||||
[tasks.wait-test-stop]
|
||||
private = true
|
||||
dependencies = ["wait-server", "test-playwright", "stop"]
|
||||
|
||||
[tasks.wait-server]
|
||||
script = '''
|
||||
sleep 1
|
||||
for run in {1..12}; do
|
||||
echo "Waiting to ensure server is started..."
|
||||
sleep 10
|
||||
done
|
||||
echo "Times up, running tests"
|
||||
'''
|
||||
|
||||
176
examples/cargo-make/scripts/web-report.sh
Executable file
176
examples/cargo-make/scripts/web-report.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -emu
|
||||
|
||||
BOLD="\e[1m"
|
||||
ITALIC="\e[3m"
|
||||
YELLOW="\e[1;33m"
|
||||
BLUE="\e[1;36m"
|
||||
RESET="\e[0m"
|
||||
|
||||
function web { #task: only include examples with web cargo-make configuration
|
||||
print_header
|
||||
print_crate_tags "$@"
|
||||
print_footer
|
||||
}
|
||||
|
||||
function all { #task: includes all examples
|
||||
print_header
|
||||
print_crate_tags "all"
|
||||
print_footer
|
||||
}
|
||||
|
||||
function print_header {
|
||||
echo -e "${YELLOW}Cargo Make Web Report${RESET}"
|
||||
echo
|
||||
echo -e "${ITALIC}Show how crates are configured to run and test web examples with cargo-make${RESET}"
|
||||
echo
|
||||
}
|
||||
|
||||
function print_crate_tags {
|
||||
local makefile_paths
|
||||
makefile_paths=$(find_makefile_lines)
|
||||
|
||||
local start_path
|
||||
start_path=$(pwd)
|
||||
|
||||
for path in $makefile_paths; do
|
||||
cd "$path"
|
||||
|
||||
local crate_tags=
|
||||
|
||||
# Add cargo tags
|
||||
while read -r line; do
|
||||
case $line in
|
||||
*"cucumber"*)
|
||||
crate_tags=$crate_tags"C"
|
||||
;;
|
||||
*"fantoccini"*)
|
||||
crate_tags=$crate_tags"F"
|
||||
;;
|
||||
*"package.metadata.leptos"*)
|
||||
crate_tags=$crate_tags"M"
|
||||
;;
|
||||
esac
|
||||
done <"./Cargo.toml"
|
||||
|
||||
#Add makefile tags
|
||||
|
||||
local pw_count
|
||||
pw_count=$(find . -name playwright.config.ts | wc -l)
|
||||
|
||||
while read -r line; do
|
||||
case $line in
|
||||
*"cargo-make/wasm-test.toml"*)
|
||||
crate_tags=$crate_tags"W"
|
||||
;;
|
||||
*"cargo-make/playwright-test.toml"*)
|
||||
crate_tags=$crate_tags"P"
|
||||
crate_tags=$crate_tags"N"
|
||||
;;
|
||||
*"cargo-make/playwright-trunk-test.toml"*)
|
||||
crate_tags=$crate_tags"P"
|
||||
crate_tags=$crate_tags"T"
|
||||
;;
|
||||
*"cargo-make/trunk_server.toml"*)
|
||||
crate_tags=$crate_tags"T"
|
||||
;;
|
||||
*"cargo-make/cargo-leptos-webdriver-test.toml"*)
|
||||
crate_tags=$crate_tags"L"
|
||||
;;
|
||||
*"cargo-make/cargo-leptos-test.toml"*)
|
||||
crate_tags=$crate_tags"L"
|
||||
if [ "$pw_count" -gt 0 ]; then
|
||||
crate_tags=$crate_tags"P"
|
||||
fi
|
||||
;;
|
||||
*"cargo-make/cargo-leptos.toml"*)
|
||||
crate_tags=$crate_tags"L"
|
||||
;;
|
||||
*"cargo-make/deno-build.toml"*)
|
||||
crate_tags=$crate_tags"D"
|
||||
;;
|
||||
esac
|
||||
done <"./Makefile.toml"
|
||||
|
||||
# Sort tags
|
||||
local keys
|
||||
keys=$(echo "$crate_tags" | grep -o . | sort | tr -d "\n")
|
||||
|
||||
# Find leptos projects that are not configured to build with cargo-leptos
|
||||
keys=${keys//"LM"/"L"}
|
||||
|
||||
# Find leptos projects that are not configured to build with deno
|
||||
keys=${keys//"DM"/"D"}
|
||||
|
||||
# Maybe print line
|
||||
local crate_line=$path
|
||||
|
||||
if [ -n "$crate_tags" ]; then
|
||||
local color=$YELLOW
|
||||
case $keys in
|
||||
*"M"*)
|
||||
color=$BLUE
|
||||
;;
|
||||
esac
|
||||
|
||||
crate_line="$crate_line ➤ ${color}$keys${RESET}"
|
||||
echo -e "$crate_line"
|
||||
elif [ "$#" -gt 0 ]; then
|
||||
crate_line="${BOLD}$crate_line${RESET}"
|
||||
echo -e "$crate_line"
|
||||
fi
|
||||
|
||||
cd "$start_path"
|
||||
done
|
||||
}
|
||||
|
||||
function find_makefile_lines {
|
||||
find . -name Makefile.toml -not -path '*/target/*' -not -path '*/node_modules/*' |
|
||||
sed 's%./%%' |
|
||||
sed 's%/Makefile.toml%%' |
|
||||
grep -v Makefile.toml |
|
||||
sort -u
|
||||
}
|
||||
|
||||
function print_footer {
|
||||
c="${BOLD}${YELLOW}C${RESET} = Cucumber Test Runner"
|
||||
d="${BOLD}${YELLOW}D${RESET} = Deno"
|
||||
f="${BOLD}${YELLOW}F${RESET} = Fantoccini WebDriver"
|
||||
l="${BOLD}${YELLOW}L${RESET} = Cargo Leptos"
|
||||
m="${BOLD}${BLUE}M${RESET} = Cargo Leptos Metadata Only (${ITALIC}ci is not configured to build with cargo-leptos or deno${RESET})"
|
||||
n="${BOLD}${YELLOW}N${RESET} = Node"
|
||||
p="${BOLD}${YELLOW}P${RESET} = Playwright Test"
|
||||
t="${BOLD}${YELLOW}T${RESET} = Trunk"
|
||||
w="${BOLD}${YELLOW}W${RESET} = WASM Test"
|
||||
|
||||
echo
|
||||
echo -e "${ITALIC}Report Keys:${RESET}\n $c\n $d\n $f\n $l\n $m\n $n\n $p\n $t\n $w"
|
||||
echo
|
||||
}
|
||||
|
||||
###################
|
||||
# HELP
|
||||
###################
|
||||
|
||||
function list_help_for {
|
||||
local task=$1
|
||||
grep -E "^function.+ #$task" "$0" |
|
||||
sed 's/function/ /' |
|
||||
sed -e "s| { #$task: |~|g" |
|
||||
column -s"~" -t |
|
||||
sort
|
||||
}
|
||||
|
||||
function help { #help: show task descriptions
|
||||
echo -e "${BOLD}Usage:${RESET} ./$(basename "$0") <task> [options]"
|
||||
echo
|
||||
echo "Show the cargo-make configuration for web examples"
|
||||
echo
|
||||
echo -e "${BOLD}Tasks:${RESET}"
|
||||
list_help_for task
|
||||
echo
|
||||
}
|
||||
|
||||
TIMEFORMAT="./web-report.sh completed in %3lR"
|
||||
time "${@:-all}" # Show the report by default
|
||||
@@ -3,18 +3,21 @@
|
||||
[tasks.stop-server]
|
||||
condition = { env_set = ["SERVER_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ ! -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
|
||||
if pidof -q ${SERVER_PROCESS_NAME}; then
|
||||
echo " Stopping ${SERVER_PROCESS_NAME}"
|
||||
pkill -ef ${SERVER_PROCESS_NAME}
|
||||
else
|
||||
echo " ${SERVER_PROCESS_NAME} is already stopped"
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.server-status]
|
||||
condition = { env_set = ["SERVER_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
|
||||
echo " ${SERVER_PROCESS_NAME} is not running"
|
||||
else
|
||||
if pidof -q ${SERVER_PROCESS_NAME}; then
|
||||
echo " ${SERVER_PROCESS_NAME} is up"
|
||||
else
|
||||
echo " ${SERVER_PROCESS_NAME} is not running"
|
||||
fi
|
||||
'''
|
||||
|
||||
@@ -24,11 +27,11 @@ script = '''
|
||||
YELLOW="\e[0;33m"
|
||||
RESET="\e[0m"
|
||||
|
||||
if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
|
||||
if pidof -q ${SERVER_PROCESS_NAME}; then
|
||||
echo " ${SERVER_PROCESS_NAME} is already started"
|
||||
else
|
||||
echo " Starting ${SERVER_PROCESS_NAME}"
|
||||
echo " ${YELLOW}>> Run cargo make stop to end process${RESET}"
|
||||
cargo make start-server ${@} &
|
||||
else
|
||||
echo " ${SERVER_PROCESS_NAME} is already started"
|
||||
fi
|
||||
'''
|
||||
|
||||
@@ -6,25 +6,33 @@ script = '''
|
||||
RESET="\e[0m"
|
||||
|
||||
if command -v chromedriver; then
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
if pidof -q chromedriver; then
|
||||
echo " chromedriver is already started"
|
||||
else
|
||||
echo "Starting chomedriver"
|
||||
chromedriver --port=4444 &
|
||||
fi
|
||||
else
|
||||
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
|
||||
echo "${RED}${BOLD}ERROR${RESET} - chromedriver not found"
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.stop-webdriver]
|
||||
script = '''
|
||||
pkill -f "chromedriver"
|
||||
if pidof -q chromedriver; then
|
||||
echo " Stopping chromedriver"
|
||||
pkill -ef "chromedriver"
|
||||
else
|
||||
echo " chromedriver is already stopped"
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.webdriver-status]
|
||||
script = '''
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
echo chromedriver is not running
|
||||
else
|
||||
if pidof -q chromedriver; then
|
||||
echo chromedriver is up
|
||||
else
|
||||
echo chromedriver is not running
|
||||
fi
|
||||
'''
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -69,7 +69,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -132,15 +132,6 @@ pub fn Counter() -> impl IntoView {
|
||||
|_| get_server_count(),
|
||||
);
|
||||
|
||||
let value =
|
||||
move || counter.get().map(|count| count.unwrap_or(0)).unwrap_or(0);
|
||||
let error_msg = move || {
|
||||
counter.get().and_then(|res| match res {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e),
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div>
|
||||
<h2>"Simple Counter"</h2>
|
||||
@@ -150,15 +141,24 @@ pub fn Counter() -> impl IntoView {
|
||||
<div>
|
||||
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
|
||||
<button on:click=move |_| dec.dispatch(())>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<span>
|
||||
"Value: "
|
||||
<Suspense>
|
||||
{move || counter.and_then(|count| *count)} "!"
|
||||
</Suspense>
|
||||
</span>
|
||||
<button on:click=move |_| inc.dispatch(())>"+1"</button>
|
||||
</div>
|
||||
{move || {
|
||||
error_msg()
|
||||
.map(|msg| {
|
||||
view! { <p>"Error: " {msg.to_string()}</p> }
|
||||
})
|
||||
}}
|
||||
<Suspense>
|
||||
{move || {
|
||||
counter.get().and_then(|res| match res {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e),
|
||||
}).map(|msg| {
|
||||
view! { <p>"Error: " {msg.to_string()}</p> }
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ pub fn FormCounter() -> impl IntoView {
|
||||
<input type="hidden" name="msg" value="form value down"/>
|
||||
<input type="submit" value="-1"/>
|
||||
</ActionForm>
|
||||
<span>"Value: " {move || value().to_string()} "!"</span>
|
||||
<span>"Value: " <Suspense>{move || value().to_string()} "!"</Suspense></span>
|
||||
<ActionForm action=adjust>
|
||||
<input type="hidden" name="delta" value="1"/>
|
||||
<input type="hidden" name="msg" value="form value up"/>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "counter_without_macros"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "counters_stable"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos", features = ["csr"] }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use leptos::{ev::click, html::AnyElement, *};
|
||||
|
||||
// no extra parameter
|
||||
pub fn highlight(el: HtmlElement<AnyElement>) {
|
||||
let mut highlighted = false;
|
||||
|
||||
@@ -14,6 +15,7 @@ pub fn highlight(el: HtmlElement<AnyElement>) {
|
||||
});
|
||||
}
|
||||
|
||||
// one extra parameter
|
||||
pub fn copy_to_clipboard(el: HtmlElement<AnyElement>, content: &str) {
|
||||
let content = content.to_string();
|
||||
|
||||
@@ -31,6 +33,35 @@ pub fn copy_to_clipboard(el: HtmlElement<AnyElement>, content: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
// custom parameter
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Amount(usize);
|
||||
|
||||
impl From<usize> for Amount {
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
// a 'default' value if no value is passed in
|
||||
impl From<()> for Amount {
|
||||
fn from(_: ()) -> Self {
|
||||
Self(1)
|
||||
}
|
||||
}
|
||||
|
||||
// .into() will automatically be called on the parameter
|
||||
pub fn add_dot(el: HtmlElement<AnyElement>, amount: Amount) {
|
||||
_ = el.clone().on(click, move |_| {
|
||||
el.set_inner_text(&format!(
|
||||
"{}{}",
|
||||
el.inner_text(),
|
||||
".".repeat(amount.0)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SomeComponent() -> impl IntoView {
|
||||
view! {
|
||||
@@ -46,6 +77,11 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<a href="#" use:copy_to_clipboard=data>"Copy \"" {data} "\" to clipboard"</a>
|
||||
// automatically applies the directive to every root element in `SomeComponent`
|
||||
<SomeComponent use:highlight />
|
||||
// no value will default to `().into()`
|
||||
<button use:add_dot>"Add a dot"</button>
|
||||
// `5.into()` automatically called
|
||||
<button use:add_dot=5>"Add 5 dots"</button>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ See the [Examples README](../README.md) for setup and run instructions.
|
||||
|
||||
## Testing
|
||||
|
||||
This project is configured to run start and stop of processes for integration tests wihtout the use of Cargo Leptos or Node.
|
||||
This project is configured to run start and stop of processes for integration tests without the use of Cargo Leptos or Node.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -61,7 +61,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
[env]
|
||||
VERIFY_GTK = false
|
||||
|
||||
[tasks.verify-flow]
|
||||
condition = { env_set = ["VERIFY_GTK"] }
|
||||
|
||||
[tasks.verify]
|
||||
condition = { env_set = ["VERIFY_GTK"] }
|
||||
@@ -70,7 +70,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -62,16 +62,18 @@ pub fn Stories() -> impl IntoView {
|
||||
}}
|
||||
</span>
|
||||
<span>"page " {page}</span>
|
||||
<span class="page-link"
|
||||
class:disabled=hide_more_link
|
||||
aria-hidden=hide_more_link
|
||||
>
|
||||
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
aria-label="Next Page"
|
||||
<Suspense>
|
||||
<span class="page-link"
|
||||
class:disabled=hide_more_link
|
||||
aria-hidden=hide_more_link
|
||||
>
|
||||
"more >"
|
||||
</a>
|
||||
</span>
|
||||
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
aria-label="Next Page"
|
||||
>
|
||||
"more >"
|
||||
</a>
|
||||
</span>
|
||||
</Suspense>
|
||||
</div>
|
||||
<main class="news-list">
|
||||
<div>
|
||||
|
||||
@@ -71,7 +71,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -81,7 +81,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
extend = [{ path = "../cargo-make/main.toml" }]
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
|
||||
CLIENT_PROCESS_NAME = "hackernews_islands"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -21,7 +21,7 @@ async fn main() {
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
logging::log!("listening on {}", addr);
|
||||
println!("listening on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
|
||||
@@ -11,21 +11,22 @@ codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[dependencies]
|
||||
console_log = "1.0"
|
||||
console_error_panic_hook = "0.1"
|
||||
leptos = { path = "../../leptos", features = ["nightly"] }
|
||||
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
|
||||
leptos_meta = { path = "../../meta", features = ["nightly"] }
|
||||
leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
log = "0.4"
|
||||
simple_logger = "4.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
console_log = "1.0.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { version = "0.5", features = ["nightly"] }
|
||||
leptos_axum = { version = "0.5", default-features = false, optional = true }
|
||||
leptos_meta = { version = "0.5", features = ["nightly"] }
|
||||
leptos_router = { version = "0.5", features = ["nightly"] }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
gloo-net = { version = "0.4", features = ["http"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
axum = { version = "0.7", default-features = false, optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
http = { version = "1.0", optional = true }
|
||||
gloo-net = { version = "0.4.0", features = ["http"] }
|
||||
reqwest = { version = "0.11.13", features = ["json"] }
|
||||
axum = { version = "0.6", default-features = false, optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
http = { version = "0.2.11", optional = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
@@ -33,10 +34,10 @@ web-sys = { version = "0.3", features = [
|
||||
"Response",
|
||||
] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = { version = "0.4", features = [
|
||||
wasm-bindgen-futures = { version = "0.4.37", features = [
|
||||
"futures-core-03-stream",
|
||||
], optional = true }
|
||||
axum-js-fetch = { version = "0.2", optional = true }
|
||||
axum-js-fetch = { version = "0.2.1", optional = true }
|
||||
lazy_static = "1.4.0"
|
||||
|
||||
[features]
|
||||
@@ -77,7 +78,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
extend = [{ path = "../cargo-make/main.toml" }]
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/deno-build.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
|
||||
CLIENT_PROCESS_NAME = "deno"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This example uses the basic Hacker News example as its basis, but shows how to run the server side as WASM running in a JS environment. In this example, Deno is used as the runtime.
|
||||
|
||||
**NOTE**: This example is slightly out of date pending an update to [`axum-js-fetch`](https://github.com/seanaye/axum-js-fetch/), which was waiting on a version of `gloo-net` that uses `http` 1.0. It still works with Leptos 0.5 and Axum 0.6, but not with the versions of Leptos (0.6 and later) that support Axum 1.0.
|
||||
|
||||
## Server Side Rendering with Deno
|
||||
|
||||
To run the Deno version, run
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn hydrate() {
|
||||
#[cfg(feature = "ssr")]
|
||||
mod ssr_imports {
|
||||
use crate::App;
|
||||
use axum::Router;
|
||||
use axum::{routing::post, Router};
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use log::{info, Level};
|
||||
@@ -52,7 +52,7 @@ mod ssr_imports {
|
||||
#[wasm_bindgen]
|
||||
impl Handler {
|
||||
pub async fn new() -> Self {
|
||||
console_log::init_with_level(Level::Debug);
|
||||
_ = console_log::init_with_level(Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let leptos_options = LeptosOptions::builder()
|
||||
@@ -62,8 +62,9 @@ mod ssr_imports {
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
// build our application with a route
|
||||
let app: axum::Router = Router::new()
|
||||
.leptos_routes(&leptos_options, routes, App)
|
||||
let app: axum::Router<(), axum::body::Body> = Router::new()
|
||||
.leptos_routes(&leptos_options, routes, || view! { <App/> })
|
||||
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
|
||||
.with_state(leptos_options);
|
||||
|
||||
info!("creating handler instance");
|
||||
|
||||
@@ -5,13 +5,13 @@ extend = [
|
||||
]
|
||||
|
||||
[tasks.build]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["build-all-features", "--target", "wasm32-unknown-unknown"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check]
|
||||
toolchain = "nightly"
|
||||
toolchain = "nightly-2024-01-29"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--target", "wasm32-unknown-unknown"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -15,7 +15,7 @@ leptos = { path = "../../leptos", features = ["nightly"] }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_meta = { path = "../../meta", features = ["nightly"] }
|
||||
leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
server_fn = { path = "../../server_fn", features = ["serde-lite", "rkyv", "multipart" ]}
|
||||
server_fn = { path = "../../server_fn", features = ["serde-lite", "rkyv", "multipart"] }
|
||||
log = "0.4"
|
||||
simple_logger = "4.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -31,6 +31,9 @@ web-sys = { version = "0.3.67", features = ["FileList", "File"] }
|
||||
strum = { version = "0.25.0", features = ["strum_macros", "derive"] }
|
||||
notify = { version = "6.1.1", optional = true }
|
||||
pin-project-lite = "0.2.13"
|
||||
dashmap = { version = "5.5.3", optional = true }
|
||||
once_cell = { version = "1.19.0", optional = true }
|
||||
async-broadcast = { version = "0.6.0", optional = true }
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
@@ -43,9 +46,11 @@ ssr = [
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
"dep:notify"
|
||||
"dep:notify",
|
||||
"dep:dashmap",
|
||||
"dep:once_cell",
|
||||
"dep:async-broadcast",
|
||||
]
|
||||
notify = ["dep:notify"]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "leptos_axum"]
|
||||
@@ -72,7 +77,7 @@ end2end-cmd = "cargo make test-ui"
|
||||
end2end-dir = "e2e"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos-webdriver-test.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
CLIENT_PROCESS_NAME = "todo_app_sqlite_axum"
|
||||
|
||||
[tasks.test-ui]
|
||||
cwd = "./e2e"
|
||||
command = "cargo"
|
||||
args = ["make", "test-ui", "${@}"]
|
||||
CLIENT_PROCESS_NAME = "server_fns_axum"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "todo_app_sqlite_axum_e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.72"
|
||||
async-trait = "0.1.72"
|
||||
cucumber = "0.19.1"
|
||||
fantoccini = "0.19.3"
|
||||
pretty_assertions = "1.4.0"
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
url = "2.4.0"
|
||||
|
||||
[[test]]
|
||||
name = "app_suite"
|
||||
harness = false # Allow Cucumber to print output instead of libtest
|
||||
@@ -1,20 +0,0 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.test]
|
||||
env = { RUN_AUTOMATICALLY = false }
|
||||
condition = { env_true = ["RUN_AUTOMATICALLY"] }
|
||||
|
||||
[tasks.ci]
|
||||
|
||||
[tasks.test-ui]
|
||||
command = "cargo"
|
||||
args = [
|
||||
"test",
|
||||
"--test",
|
||||
"app_suite",
|
||||
"--",
|
||||
"--retry",
|
||||
"2",
|
||||
"--fail-fast",
|
||||
"${@}",
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
# E2E Testing
|
||||
|
||||
This example demonstrates e2e testing with Rust using executable requirements.
|
||||
|
||||
## Testing Stack
|
||||
|
||||
| | Role | Description |
|
||||
|---|---|---|
|
||||
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
|
||||
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
|
||||
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
|
||||
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
|
||||
|
||||
## Testing Organization
|
||||
|
||||
Testing is organized around what a user can do and see/not see. Test scenarios are grouped by the **user action** and the **object** of that action. This makes it easier to locate and reason about requirements.
|
||||
|
||||
Here is a brief overview of how things fit together.
|
||||
|
||||
```bash
|
||||
features
|
||||
└── {action}_{object}.feature # Specify test scenarios
|
||||
tests
|
||||
├── fixtures
|
||||
│ ├── action.rs # Perform a user action (click, type, etc.)
|
||||
│ ├── check.rs # Assert what a user can see/not see
|
||||
│ ├── find.rs # Query page elements
|
||||
│ ├── mod.rs
|
||||
│ └── world
|
||||
│ ├── action_steps.rs # Map Gherkin steps to user actions
|
||||
│ ├── check_steps.rs # Map Gherkin steps to user expectations
|
||||
│ └── mod.rs
|
||||
└── app_suite.rs # Test main
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
@add_todo
|
||||
Feature: Add Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@add_todo-see
|
||||
Scenario: Should see the todo
|
||||
Given I set the todo as Buy Bread
|
||||
When I click the Add button
|
||||
Then I see the todo named Buy Bread
|
||||
|
||||
@add_todo-style
|
||||
Scenario: Should see the pending todo
|
||||
When I add a todo as Buy Oranges
|
||||
Then I see the pending todo
|
||||
@@ -1,18 +0,0 @@
|
||||
@delete_todo
|
||||
Feature: Delete Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@serial
|
||||
@delete_todo-remove
|
||||
Scenario: Should not see the deleted todo
|
||||
Given I add a todo as Buy Yogurt
|
||||
When I delete the todo named Buy Yogurt
|
||||
Then I do not see the todo named Buy Yogurt
|
||||
|
||||
@serial
|
||||
@delete_todo-message
|
||||
Scenario: Should see the empty list message
|
||||
When I empty the todo list
|
||||
Then I see the empty list message is No tasks were found.
|
||||
@@ -1,12 +0,0 @@
|
||||
@open_app
|
||||
Feature: Open App
|
||||
|
||||
@open_app-title
|
||||
Scenario: Should see the home page title
|
||||
When I open the app
|
||||
Then I see the page title is My Tasks
|
||||
|
||||
@open_app-label
|
||||
Scenario: Should see the input label
|
||||
When I open the app
|
||||
Then I see the label of the input is Add a Todo
|
||||
@@ -1,14 +0,0 @@
|
||||
mod fixtures;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fixtures::world::AppWorld;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
AppWorld::cucumber()
|
||||
.fail_on_skipped()
|
||||
.run_and_exit("./features")
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
use super::{find, world::HOST};
|
||||
use anyhow::Result;
|
||||
use fantoccini::Client;
|
||||
use std::result::Result::Ok;
|
||||
use tokio::{self, time};
|
||||
|
||||
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
|
||||
let url = format!("{}{}", HOST, path);
|
||||
client.goto(&url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
|
||||
fill_todo(client, text).await?;
|
||||
click_add_button(client).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
|
||||
let textbox = find::todo_input(client).await;
|
||||
textbox.send_keys(text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_add_button(client: &Client) -> Result<()> {
|
||||
let add_button = find::add_button(client).await;
|
||||
add_button.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn empty_todo_list(client: &Client) -> Result<()> {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for _todo in todos {
|
||||
let _ = delete_first_todo(client).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_first_todo(client: &Client) -> Result<()> {
|
||||
if let Some(element) = find::first_delete_button(client).await {
|
||||
element.click().await.expect("Failed to delete todo");
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
|
||||
if let Some(element) = find::delete_button(client, text).await {
|
||||
element.click().await?;
|
||||
time::sleep(time::Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use super::find;
|
||||
use anyhow::{Ok, Result};
|
||||
use fantoccini::{Client, Locator};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
pub async fn text_on_element(
|
||||
client: &Client,
|
||||
selector: &str,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Css(selector))
|
||||
.await
|
||||
.expect(
|
||||
format!("Element not found by Css selector `{}`", selector)
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let actual = element.text().await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn todo_present(
|
||||
client: &Client,
|
||||
text: &str,
|
||||
expected: bool,
|
||||
) -> Result<()> {
|
||||
let todo_present = is_todo_present(client, text).await;
|
||||
|
||||
assert_eq!(todo_present, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_todo_present(client: &Client, text: &str) -> bool {
|
||||
let todos = find::todos(client).await;
|
||||
|
||||
for todo in todos {
|
||||
let todo_title = todo.text().await.expect("Todo title not found");
|
||||
if todo_title == text {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn todo_is_pending(client: &Client) -> Result<()> {
|
||||
if let None = find::pending_todo(client).await {
|
||||
assert!(false, "Pending todo not found");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
use fantoccini::{elements::Element, Client, Locator};
|
||||
|
||||
pub async fn todo_input(client: &Client) -> Element {
|
||||
let textbox = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[name='title"))
|
||||
.await
|
||||
.expect("Todo textbox not found");
|
||||
|
||||
textbox
|
||||
}
|
||||
|
||||
pub async fn add_button(client: &Client) -> Element {
|
||||
let button = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("input[value='Add']"))
|
||||
.await
|
||||
.expect("");
|
||||
|
||||
button
|
||||
}
|
||||
|
||||
pub async fn first_delete_button(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("li:first-child input[value='X']"))
|
||||
.await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
|
||||
let selector = format!("//*[text()='{text}']//input[@value='X']");
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::XPath(&selector)).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn pending_todo(client: &Client) -> Option<Element> {
|
||||
if let Ok(element) =
|
||||
client.wait().for_element(Locator::Css(".pending")).await
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn todos(client: &Client) -> Vec<Element> {
|
||||
let todos = client
|
||||
.find_all(Locator::Css("li"))
|
||||
.await
|
||||
.expect("Todo List not found");
|
||||
|
||||
todos
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod action;
|
||||
pub mod check;
|
||||
pub mod find;
|
||||
pub mod world;
|
||||
@@ -1,57 +0,0 @@
|
||||
use crate::fixtures::{action, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::{given, when};
|
||||
|
||||
#[given("I see the app")]
|
||||
#[when("I open the app")]
|
||||
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::goto_path(client, "").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I add a todo as (.*)$")]
|
||||
#[when(regex = "^I add a todo as (.*)$")]
|
||||
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::add_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I set the todo as (.*)$")]
|
||||
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::fill_todo(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "I click the Add button$")]
|
||||
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_add_button(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(regex = "^I delete the todo named (.*)$")]
|
||||
async fn i_delete_the_todo_named(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::delete_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given("the todo list is empty")]
|
||||
#[when("I empty the todo list")]
|
||||
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::empty_todo_list(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use crate::fixtures::{check, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
|
||||
#[then(regex = "^I see the page title is (.*)$")]
|
||||
async fn i_see_the_page_title_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "h1", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the label of the input is (.*)$")]
|
||||
async fn i_see_the_label_of_the_input_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "label", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the todo named (.*)$")]
|
||||
async fn i_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), true).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then("I see the pending todo")]
|
||||
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
check::todo_is_pending(client).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I see the empty list message is (.*)$")]
|
||||
async fn i_see_the_empty_list_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::text_on_element(client, "ul p", &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = "^I do not see the todo named (.*)$")]
|
||||
async fn i_do_not_see_the_todo_is_present(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::todo_present(client, text.as_str(), false).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
pub mod action_steps;
|
||||
pub mod check_steps;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fantoccini::{
|
||||
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
|
||||
};
|
||||
|
||||
pub const HOST: &str = "http://127.0.0.1:3000";
|
||||
|
||||
#[derive(Debug, World)]
|
||||
#[world(init = Self::new)]
|
||||
pub struct AppWorld {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppWorld {
|
||||
async fn new() -> Result<Self, anyhow::Error> {
|
||||
let webdriver_client = build_client().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: webdriver_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_client() -> Result<Client, NewSessionError> {
|
||||
let mut cap = Capabilities::new();
|
||||
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
|
||||
cap.insert("goog:chromeOptions".to_string(), arg);
|
||||
|
||||
let client = ClientBuilder::native()
|
||||
.capabilities(cap)
|
||||
.connect("http://localhost:4444")
|
||||
.await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
use futures::StreamExt;
|
||||
use http::Method;
|
||||
use leptos::{html::Input, *};
|
||||
use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet};
|
||||
use leptos_router::{ActionForm, Route, Router, Routes};
|
||||
use server_fn::codec::{
|
||||
GetUrl, MultipartData, MultipartFormData, Rkyv, SerdeLite, StreamingText,
|
||||
TextStream,
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use server_fn::{
|
||||
client::{browser::BrowserClient, Client},
|
||||
codec::{
|
||||
Encoding, FromReq, FromRes, GetUrl, IntoReq, IntoRes, MultipartData,
|
||||
MultipartFormData, Rkyv, SerdeLite, StreamingText, TextStream,
|
||||
},
|
||||
request::{browser::BrowserRequest, ClientReq, Req},
|
||||
response::{browser::BrowserResponse, ClientRes, Res},
|
||||
};
|
||||
use std::future::Future;
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicU8, Ordering},
|
||||
@@ -49,7 +57,10 @@ pub fn HomePage() -> impl IntoView {
|
||||
<ServerFnArgumentExample/>
|
||||
<RkyvExample/>
|
||||
<FileUpload/>
|
||||
<FileUploadWithProgress/>
|
||||
<FileWatcher/>
|
||||
<CustomEncoding/>
|
||||
<CustomClientExample/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,15 +179,12 @@ pub fn WithAnAction() -> impl IntoView {
|
||||
<button
|
||||
on:click=move |_| {
|
||||
let text = input_ref.get().unwrap().value();
|
||||
action.dispatch(text);
|
||||
action.dispatch(text.into());
|
||||
// note: technically, this `action` takes `AddRow` (the server fn type) as its
|
||||
// argument
|
||||
//
|
||||
// however, `.dispatch()` takes `impl Into<I>`, and for any one-argument server
|
||||
// functions, `From<_>` is implemented between the server function type and the
|
||||
// type of this single argument
|
||||
//
|
||||
// so `action.dispatch(text)` means `action.dispatch(AddRow { text })`
|
||||
// however, for any one-argument server functions, `From<_>` is implemented between
|
||||
// the server function type and the type of this single argument
|
||||
}
|
||||
>
|
||||
Submit
|
||||
@@ -312,7 +320,7 @@ pub fn RkyvExample() -> impl IntoView {
|
||||
set_input(value);
|
||||
}
|
||||
>
|
||||
Click to see length
|
||||
Click to capitalize
|
||||
</button>
|
||||
<p>{input}</p>
|
||||
<Transition>
|
||||
@@ -327,8 +335,8 @@ pub fn FileUpload() -> impl IntoView {
|
||||
///
|
||||
/// On the server, this uses the `multer` crate, which provides a streaming API.
|
||||
#[server(
|
||||
input = MultipartFormData,
|
||||
)]
|
||||
input = MultipartFormData,
|
||||
)]
|
||||
pub async fn file_length(
|
||||
data: MultipartData,
|
||||
) -> Result<usize, ServerFnError> {
|
||||
@@ -386,6 +394,168 @@ pub fn FileUpload() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// This component uses server functions to upload a file, while streaming updates on the upload
|
||||
/// progress.
|
||||
#[component]
|
||||
pub fn FileUploadWithProgress() -> impl IntoView {
|
||||
/// In theory, you could create a single server function which
|
||||
/// 1) received multipart form data
|
||||
/// 2) returned a stream that contained updates on the progress
|
||||
///
|
||||
/// In reality, browsers do not actually support duplexing requests in this way. In other
|
||||
/// words, every existing browser actually requires that the request stream be complete before
|
||||
/// it begins processing the response stream.
|
||||
///
|
||||
/// Instead, we can create two separate server functions:
|
||||
/// 1) one that receives multipart form data and begins processing the upload
|
||||
/// 2) a second that returns a stream of updates on the progress
|
||||
///
|
||||
/// This requires us to store some global state of all the uploads. In a real app, you probably
|
||||
/// shouldn't do exactly what I'm doing here in the demo. For example, this map just
|
||||
/// distinguishes between files by filename, not by user.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod progress {
|
||||
use async_broadcast::{broadcast, Receiver, Sender};
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
struct File {
|
||||
total: usize,
|
||||
tx: Sender<usize>,
|
||||
rx: Receiver<usize>,
|
||||
}
|
||||
|
||||
static FILES: Lazy<DashMap<String, File>> = Lazy::new(DashMap::new);
|
||||
|
||||
pub async fn add_chunk(filename: &str, len: usize) {
|
||||
println!("[{filename}]\tadding {len}");
|
||||
let mut entry =
|
||||
FILES.entry(filename.to_string()).or_insert_with(|| {
|
||||
println!("[{filename}]\tinserting channel");
|
||||
let (tx, rx) = broadcast(128);
|
||||
File { total: 0, tx, rx }
|
||||
});
|
||||
entry.total += len;
|
||||
let new_total = entry.total;
|
||||
|
||||
// we're about to do an async broadcast, so we don't want to hold a lock across it
|
||||
let tx = entry.tx.clone();
|
||||
drop(entry);
|
||||
|
||||
// now we send the message and don't have to worry about it
|
||||
tx.broadcast(new_total)
|
||||
.await
|
||||
.expect("couldn't send a message over channel");
|
||||
}
|
||||
|
||||
pub fn for_file(filename: &str) -> impl Stream<Item = usize> {
|
||||
let entry =
|
||||
FILES.entry(filename.to_string()).or_insert_with(|| {
|
||||
println!("[{filename}]\tinserting channel");
|
||||
let (tx, rx) = broadcast(128);
|
||||
File { total: 0, tx, rx }
|
||||
});
|
||||
entry.rx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = MultipartFormData,
|
||||
)]
|
||||
pub async fn upload_file(data: MultipartData) -> Result<(), ServerFnError> {
|
||||
let mut data = data.into_inner().unwrap();
|
||||
|
||||
while let Ok(Some(mut field)) = data.next_field().await {
|
||||
let name =
|
||||
field.file_name().expect("no filename on field").to_string();
|
||||
while let Ok(Some(chunk)) = field.chunk().await {
|
||||
let len = chunk.len();
|
||||
println!("[{name}]\t{len}");
|
||||
progress::add_chunk(&name, len).await;
|
||||
// in a real server function, you'd do something like saving the file here
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(output = StreamingText)]
|
||||
pub async fn file_progress(
|
||||
filename: String,
|
||||
) -> Result<TextStream, ServerFnError> {
|
||||
println!("getting progress on {filename}");
|
||||
// get the stream of current length for the file
|
||||
let progress = progress::for_file(&filename);
|
||||
// separate each number with a newline
|
||||
// the HTTP response might pack multiple lines of this into a single chunk
|
||||
// we need some way of dividing them up
|
||||
let progress = progress.map(|bytes| Ok(format!("{bytes}\n")));
|
||||
Ok(TextStream::new(progress))
|
||||
}
|
||||
|
||||
let (filename, set_filename) = create_signal(None);
|
||||
let (max, set_max) = create_signal(None);
|
||||
let (current, set_current) = create_signal(None);
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let target = ev.target().unwrap().unchecked_into::<HtmlFormElement>();
|
||||
let form_data = FormData::new_with_form(&target).unwrap();
|
||||
let file = form_data
|
||||
.get("file_to_upload")
|
||||
.unchecked_into::<web_sys::File>();
|
||||
let filename = file.name();
|
||||
let size = file.size() as usize;
|
||||
set_filename(Some(filename.clone()));
|
||||
set_max(Some(size));
|
||||
set_current(None::<usize>);
|
||||
|
||||
spawn_local(async move {
|
||||
let mut progress = file_progress(filename)
|
||||
.await
|
||||
.expect("couldn't initialize stream")
|
||||
.into_inner();
|
||||
while let Some(Ok(len)) = progress.next().await {
|
||||
// the TextStream from the server function will be a series of `usize` values
|
||||
// however, the response itself may pack those chunks into a smaller number of
|
||||
// chunks, each with more text in it
|
||||
// so we've padded them with newspace, and will split them out here
|
||||
// each value is the latest total, so we'll just take the last one
|
||||
let len = len
|
||||
.split('\n')
|
||||
.filter(|n| !n.is_empty())
|
||||
.last()
|
||||
.expect(
|
||||
"expected at least one non-empty value from \
|
||||
newline-delimited rows",
|
||||
)
|
||||
.parse::<usize>()
|
||||
.expect("invalid length");
|
||||
set_current(Some(len));
|
||||
}
|
||||
});
|
||||
spawn_local(async move {
|
||||
upload_file(form_data.into())
|
||||
.await
|
||||
.expect("couldn't upload file");
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<h3>File Upload with Progress</h3>
|
||||
<p>A file upload with progress can be handled with two separate server functions.</p>
|
||||
<aside>See the doc comment on the component for an explanation.</aside>
|
||||
<form on:submit=on_submit>
|
||||
<input type="file" name="file_to_upload"/>
|
||||
<input type="submit"/>
|
||||
</form>
|
||||
{move || filename().map(|filename| view! { <p>Uploading {filename}</p> })}
|
||||
{move || max().map(|max| view! {
|
||||
<progress max=max value=move || current().unwrap_or_default()/>
|
||||
})}
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
pub fn FileWatcher() -> impl IntoView {
|
||||
#[server(input = GetUrl, output = StreamingText)]
|
||||
@@ -506,3 +676,177 @@ pub fn CustomErrorTypes() -> impl IntoView {
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
/// Server function encodings are just types that implement a few traits.
|
||||
/// This means that you can implement your own encodings, by implementing those traits!
|
||||
///
|
||||
/// Here, we'll create a custom encoding that serializes and deserializes the server fn
|
||||
/// using TOML. Why would you ever want to do this? I don't know, but you can!
|
||||
pub struct Toml;
|
||||
|
||||
/// A newtype wrapper around server fn data that will be TOML-encoded.
|
||||
///
|
||||
/// This is needed because of Rust rules around implementing foreign traits for foreign types.
|
||||
/// It will be fed into the `custom = ` argument to the server fn below.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TomlEncoded<T>(T);
|
||||
|
||||
impl Encoding for Toml {
|
||||
const CONTENT_TYPE: &'static str = "application/toml";
|
||||
const METHOD: Method = Method::POST;
|
||||
}
|
||||
|
||||
impl<T, Request, Err> IntoReq<Toml, Request, Err> for TomlEncoded<T>
|
||||
where
|
||||
Request: ClientReq<Err>,
|
||||
T: Serialize,
|
||||
{
|
||||
fn into_req(
|
||||
self,
|
||||
path: &str,
|
||||
accepts: &str,
|
||||
) -> Result<Request, ServerFnError<Err>> {
|
||||
let data = toml::to_string(&self.0)
|
||||
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
Request::try_new_post(path, Toml::CONTENT_TYPE, accepts, data)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Request, Err> FromReq<Toml, Request, Err> for TomlEncoded<T>
|
||||
where
|
||||
Request: Req<Err> + Send,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> {
|
||||
let string_data = req.try_into_string().await?;
|
||||
toml::from_str::<T>(&string_data)
|
||||
.map(TomlEncoded)
|
||||
.map_err(|e| ServerFnError::Args(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Response, Err> IntoRes<Toml, Response, Err> for TomlEncoded<T>
|
||||
where
|
||||
Response: Res<Err>,
|
||||
T: Serialize + Send,
|
||||
{
|
||||
async fn into_res(self) -> Result<Response, ServerFnError<Err>> {
|
||||
let data = toml::to_string(&self.0)
|
||||
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
Response::try_from_string(Toml::CONTENT_TYPE, data)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, Response, Err> FromRes<Toml, Response, Err> for TomlEncoded<T>
|
||||
where
|
||||
Response: ClientRes<Err> + Send,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> {
|
||||
let data = res.try_into_string().await?;
|
||||
toml::from_str(&data)
|
||||
.map(TomlEncoded)
|
||||
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WhyNotResult {
|
||||
original: String,
|
||||
modified: String,
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = Toml,
|
||||
output = Toml,
|
||||
custom = TomlEncoded
|
||||
)]
|
||||
pub async fn why_not(
|
||||
original: String,
|
||||
addition: String,
|
||||
) -> Result<TomlEncoded<WhyNotResult>, ServerFnError> {
|
||||
// insert a simulated wait
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
Ok(TomlEncoded(WhyNotResult {
|
||||
modified: format!("{original}{addition}"),
|
||||
original,
|
||||
}))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CustomEncoding() -> impl IntoView {
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
let (result, set_result) = create_signal("foo".to_string());
|
||||
|
||||
view! {
|
||||
<h3>Custom encodings</h3>
|
||||
<p>
|
||||
"This example creates a custom encoding that sends server fn data using TOML. Why? Well... why not?"
|
||||
</p>
|
||||
<input node_ref=input_ref placeholder="Type something here."/>
|
||||
<button
|
||||
on:click=move |_| {
|
||||
let value = input_ref.get().unwrap().value();
|
||||
spawn_local(async move {
|
||||
let new_value = why_not(value, ", but in TOML!!!".to_string()).await.unwrap();
|
||||
set_result(new_value.0.modified);
|
||||
});
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<p>{result}</p>
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware lets you modify the request/response on the server.
|
||||
///
|
||||
/// On the client, you might also want to modify the request. For example, you may need to add a
|
||||
/// custom header for authentication on every request. You can do this by creating a "custom
|
||||
/// client."
|
||||
#[component]
|
||||
pub fn CustomClientExample() -> impl IntoView {
|
||||
// Define a type for our client.
|
||||
pub struct CustomClient;
|
||||
|
||||
// Implement the `Client` trait for it.
|
||||
impl<CustErr> Client<CustErr> for CustomClient {
|
||||
// BrowserRequest and BrowserResponse are the defaults used by other server functions.
|
||||
// They are wrappers for the underlying Web Fetch API types.
|
||||
type Request = BrowserRequest;
|
||||
type Response = BrowserResponse;
|
||||
|
||||
// Our custom `send()` implementation does all the work.
|
||||
fn send(
|
||||
req: Self::Request,
|
||||
) -> impl Future<Output = Result<Self::Response, ServerFnError<CustErr>>>
|
||||
+ Send {
|
||||
// BrowserRequest derefs to the underlying Request type from gloo-net,
|
||||
// so we can get access to the headers here
|
||||
let headers = req.headers();
|
||||
// modify the headers by appending one
|
||||
headers.append("X-Custom-Header", "foobar");
|
||||
// delegate back out to BrowserClient to send the modified request
|
||||
BrowserClient::send(req)
|
||||
}
|
||||
}
|
||||
|
||||
// Specify our custom client with `client = `
|
||||
#[server(client = CustomClient)]
|
||||
pub async fn fn_with_custom_client() -> Result<(), ServerFnError> {
|
||||
use http::header::HeaderMap;
|
||||
use leptos_axum::extract;
|
||||
|
||||
let headers: HeaderMap = extract().await?;
|
||||
let custom_header = headers.get("X-Custom-Header");
|
||||
println!("X-Custom-Header = {custom_header:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
view! {
|
||||
<h3>Custom clients</h3>
|
||||
<p>You can define a custom server function client to do something like adding a header to every request.</p>
|
||||
<p>Check the network request in your browser devtools to see how this client adds a custom header.</p>
|
||||
<button on:click=|_| spawn_local(async { fn_with_custom_client().await.unwrap() })>Click me</button>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
log = "0.4"
|
||||
simple_logger = "4.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
axum = { version = "0.7", optional = true, features=["macros"] }
|
||||
axum = { version = "0.7", optional = true, features = ["macros"] }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
@@ -30,10 +30,10 @@ sqlx = { version = "0.7.2", features = [
|
||||
], optional = true }
|
||||
thiserror = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
axum_session_auth = { version = "0.10", features = [
|
||||
axum_session_auth = { version = "0.12.1", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
axum_session = { version = "0.10", features = [
|
||||
axum_session = { version = "0.12.4", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
bcrypt = { version = "0.15", optional = true }
|
||||
@@ -83,7 +83,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -51,7 +51,7 @@ pub mod ssr {
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
@@ -75,7 +75,7 @@ pub mod ssr {
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ async fn main() {
|
||||
SessionConfig::default().with_table_name("axum_sessions");
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(
|
||||
Some(pool.clone().into()),
|
||||
Some(SessionSqlitePool::from(pool.clone())),
|
||||
session_config,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use leptos::*;
|
||||
|
||||
// Slots are created in simillar manner to components, except that they use the #[slot] macro.
|
||||
// Slots are created in similar manner to components, except that they use the #[slot] macro.
|
||||
#[slot]
|
||||
struct Then {
|
||||
children: ChildrenFn,
|
||||
|
||||
@@ -7,40 +7,39 @@ edition = "2021"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
oauth2 = {version="4.4.2",optional=true}
|
||||
oauth2 = { version = "4.4.2", optional = true }
|
||||
anyhow = "1.0.66"
|
||||
console_log = "1.0.0"
|
||||
rand = { version = "0.8.5", features = ["min_const_gen"], optional = true }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { path = "../../leptos"}
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_meta = { path = "../../meta" }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router"}
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
serde_json = {version="1.0.108", optional = true }
|
||||
axum = { version = "0.6.1", optional = true, features=["macros"] }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.4", features = ["fs"], optional = true }
|
||||
serde_json = { version = "1.0.108", optional = true }
|
||||
axum = { version = "0.7", optional = true, features = ["macros"] }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8" }
|
||||
http = { version = "1" }
|
||||
sqlx = { version = "0.7", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0.38"
|
||||
wasm-bindgen = "0.2"
|
||||
axum_session_auth = { version = "0.9", features = [
|
||||
axum_session_auth = { version = "0.12", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
axum_session = { version = "0.9", features = [
|
||||
axum_session = { version = "0.12", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
async-trait = { version = "0.1.64", optional = true }
|
||||
reqwest= {version="0.11",optional=true, features=["json"]}
|
||||
reqwest = { version = "0.11", optional = true, features = ["json"] }
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
@@ -63,7 +62,9 @@ ssr = [
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
@@ -85,7 +86,7 @@ reload-port = 3001
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
extend = { path = "../cargo-make/main.toml" }
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "build-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
[env]
|
||||
|
||||
[tasks.check]
|
||||
command = "cargo"
|
||||
args = ["+nightly", "check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
CLIENT_PROCESS_NAME = "sso_auth_axum"
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
use cfg_if::cfg_if;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use sqlx::SqlitePool;
|
||||
use axum_session_auth::{SessionSqlitePool, Authentication, HasPermission};
|
||||
pub type AuthSession = axum_session_auth::AuthSession<User, i64, SessionSqlitePool, SqlitePool>;
|
||||
}}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
@@ -28,38 +20,58 @@ impl Default for User {
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr_imports {
|
||||
use super::User;
|
||||
pub use axum_session_auth::{
|
||||
Authentication, HasPermission, SessionSqlitePool,
|
||||
};
|
||||
pub use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
pub type AuthSession = axum_session_auth::AuthSession<
|
||||
User,
|
||||
i64,
|
||||
SessionSqlitePool,
|
||||
SqlitePool,
|
||||
>;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
impl User {
|
||||
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
|
||||
pub async fn get_from_email(email: &str, pool: &SqlitePool) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(email)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
pub async fn get_from_email(
|
||||
email: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE email = ?",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
@@ -84,7 +96,10 @@ if #[cfg(feature = "ssr")] {
|
||||
|
||||
#[async_trait]
|
||||
impl Authentication<User, i64, SqlitePool> for User {
|
||||
async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result<User, anyhow::Error> {
|
||||
async fn load_user(
|
||||
userid: i64,
|
||||
pool: Option<&SqlitePool>,
|
||||
) -> Result<User, anyhow::Error> {
|
||||
let pool = pool.unwrap();
|
||||
|
||||
User::get(userid, pool)
|
||||
@@ -123,9 +138,11 @@ if #[cfg(feature = "ssr")] {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
|
||||
impl SqlUser {
|
||||
pub fn into_user(self, sql_user_perms: Option<Vec<SqlPermissionTokens>>) -> User {
|
||||
pub fn into_user(
|
||||
self,
|
||||
sql_user_perms: Option<Vec<SqlPermissionTokens>>,
|
||||
) -> User {
|
||||
User {
|
||||
id: self.id,
|
||||
email: self.email,
|
||||
@@ -141,4 +158,3 @@ if #[cfg(feature = "ssr")] {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub fn error_template(errors: RwSignal<Errors>) -> View {
|
||||
children= move | (_, error)| {
|
||||
let error_string = error.to_string();
|
||||
view! {
|
||||
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
use cfg_if::cfg_if;
|
||||
use crate::error_template::error_template;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
response::{IntoResponse, Response as AxumResponse},
|
||||
};
|
||||
use leptos::*;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
extract::State,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use leptos::*;
|
||||
use crate::error_template::error_template;
|
||||
pub async fn file_and_error_handler(
|
||||
uri: Uri,
|
||||
State(options): State<LeptosOptions>,
|
||||
req: Request<Body>,
|
||||
) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
leptos::logging::log!("{:?}:{}",res.status(),uri);
|
||||
let handler = leptos_axum::render_app_to_stream(
|
||||
options.to_owned(),
|
||||
|| error_template(create_rw_signal(leptos::Errors::default())
|
||||
)
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
)),
|
||||
}
|
||||
}
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
leptos::logging::log!("{:?}:{}", res.status(), uri);
|
||||
let handler =
|
||||
leptos_axum::render_app_to_stream(options.to_owned(), || {
|
||||
error_template(create_rw_signal(leptos::Errors::default()))
|
||||
});
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
let req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
pub mod auth;
|
||||
pub mod error_template;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod fallback;
|
||||
pub mod sign_in_sign_up;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod state;
|
||||
use leptos::{leptos_dom::helpers::TimeoutHandle, *};
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use sign_in_sign_up::*;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::{
|
||||
state::AppState,
|
||||
auth::{AuthSession,User,SqlRefreshToken}
|
||||
};
|
||||
use oauth2::{
|
||||
reqwest::async_http_client,
|
||||
TokenResponse
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
#[cfg(feature = "ssr")]
|
||||
mod ssr_imports {
|
||||
pub use crate::auth::ssr_imports::{AuthSession, SqlRefreshToken};
|
||||
pub use leptos::{use_context, ServerFnError};
|
||||
pub use oauth2::{reqwest::async_http_client, TokenResponse};
|
||||
pub use sqlx::SqlitePool;
|
||||
|
||||
pub fn pool() -> Result<SqlitePool, ServerFnError> {
|
||||
use_context::<SqlitePool>()
|
||||
.ok_or_else(|| ServerFnError::ServerError("Pool missing.".into()))
|
||||
}
|
||||
pub fn pool() -> Result<SqlitePool, ServerFnError> {
|
||||
use_context::<SqlitePool>()
|
||||
.ok_or_else(|| ServerFnError::new("Pool missing."))
|
||||
}
|
||||
|
||||
pub fn auth() -> Result<AuthSession, ServerFnError> {
|
||||
use_context::<AuthSession>()
|
||||
.ok_or_else(|| ServerFnError::ServerError("Auth session missing.".into()))
|
||||
}
|
||||
pub fn auth() -> Result<AuthSession, ServerFnError> {
|
||||
use_context::<AuthSession>()
|
||||
.ok_or_else(|| ServerFnError::new("Auth session missing."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +34,14 @@ pub struct Email(RwSignal<Option<String>>);
|
||||
pub struct ExpiresIn(RwSignal<u64>);
|
||||
#[server]
|
||||
pub async fn refresh_token(email: String) -> Result<u64, ServerFnError> {
|
||||
use crate::{auth::User, state::AppState};
|
||||
use ssr_imports::*;
|
||||
|
||||
let pool = pool()?;
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let user = User::get_from_email(&email, &pool)
|
||||
.await
|
||||
.ok_or(ServerFnError::ServerError("User not found".to_string()))?;
|
||||
.ok_or(ServerFnError::new("User not found"))?;
|
||||
|
||||
let refresh_secret = sqlx::query_as::<_, SqlRefreshToken>(
|
||||
"SELECT secret FROM google_refresh_tokens WHERE user_id = ?",
|
||||
@@ -77,6 +74,7 @@ pub async fn refresh_token(email: String) -> Result<u64, ServerFnError> {
|
||||
.await?;
|
||||
Ok(expires_in)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
@@ -143,20 +141,11 @@ pub fn App() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use leptos::view;
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(|| {
|
||||
view! { <App/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
leptos::mount_to_body(App);
|
||||
}
|
||||
|
||||
@@ -1,136 +1,154 @@
|
||||
use cfg_if::cfg_if;
|
||||
use crate::ssr_imports::*;
|
||||
use axum::{
|
||||
body::Body as AxumBody,
|
||||
extract::{Path, State},
|
||||
http::Request,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_session::{Key, SessionConfig, SessionLayer, SessionStore};
|
||||
use axum_session_auth::{AuthConfig, AuthSessionLayer, SessionSqlitePool};
|
||||
use leptos::{get_configuration, logging::log, provide_context, view};
|
||||
use leptos_axum::{
|
||||
generate_route_list, handle_server_fns_with_context, LeptosRoutes,
|
||||
};
|
||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use sso_auth_axum::{
|
||||
auth::*, fallback::file_and_error_handler, state::AppState,
|
||||
};
|
||||
|
||||
// boilerplate to run in different modes
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
response::{IntoResponse},
|
||||
routing::get,
|
||||
extract::{Path, State, RawQuery},
|
||||
http::{Request, header::HeaderMap},
|
||||
body::Body as AxumBody,
|
||||
Router,
|
||||
};
|
||||
use sso_auth_axum::auth::*;
|
||||
use sso_auth_axum::state::AppState;
|
||||
use sso_auth_axum::fallback::file_and_error_handler;
|
||||
use leptos_axum::{generate_route_list, handle_server_fns_with_context, LeptosRoutes};
|
||||
use leptos::{logging::log, view, provide_context, get_configuration};
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
use axum_session::{SessionConfig, SessionLayer, SessionStore,Key, SecurityMode};
|
||||
use axum_session_auth::{AuthSessionLayer, AuthConfig, SessionSqlitePool};
|
||||
async fn server_fn_handler(
|
||||
State(app_state): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
path: Path<String>,
|
||||
request: Request<AxumBody>,
|
||||
) -> impl IntoResponse {
|
||||
log!("{:?}", path);
|
||||
|
||||
async fn server_fn_handler(State(app_state): State<AppState>, auth_session: AuthSession, path: Path<String>, headers: HeaderMap, raw_query: RawQuery,
|
||||
request: Request<AxumBody>) -> impl IntoResponse {
|
||||
|
||||
log!("{:?}", path);
|
||||
|
||||
handle_server_fns_with_context(path, headers, raw_query, move || {
|
||||
handle_server_fns_with_context(
|
||||
move || {
|
||||
provide_context(app_state.clone());
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
}, request).await
|
||||
}
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn leptos_routes_handler(
|
||||
auth_session: AuthSession,
|
||||
State(app_state): State<AppState>,
|
||||
axum::extract::State(option): axum::extract::State<leptos::LeptosOptions>,
|
||||
request: Request<AxumBody>,
|
||||
) -> axum::response::Response {
|
||||
let handler = leptos_axum::render_app_async_with_context(
|
||||
option.clone(),
|
||||
move || {
|
||||
provide_context(app_state.clone());
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
move || view! { <sso_auth_axum::App/> },
|
||||
);
|
||||
pub async fn leptos_routes_handler(
|
||||
auth_session: AuthSession,
|
||||
State(app_state): State<AppState>,
|
||||
axum::extract::State(option): axum::extract::State<leptos::LeptosOptions>,
|
||||
request: Request<AxumBody>,
|
||||
) -> axum::response::Response {
|
||||
let handler = leptos_axum::render_app_async_with_context(
|
||||
option.clone(),
|
||||
move || {
|
||||
provide_context(app_state.clone());
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
move || view! { <sso_auth_axum::App/> },
|
||||
);
|
||||
|
||||
handler(request).await.into_response()
|
||||
}
|
||||
handler(request).await.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info)
|
||||
.expect("couldn't initialize logging");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging");
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite:sso.db")
|
||||
.await
|
||||
.expect("Could not make pool.");
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite:sso.db")
|
||||
.await
|
||||
.expect("Could not make pool.");
|
||||
// Auth section
|
||||
let session_config = SessionConfig::default()
|
||||
.with_table_name("sessions_table")
|
||||
.with_key(Key::generate())
|
||||
.with_database_key(Key::generate());
|
||||
// .with_security_mode(SecurityMode::PerSession); // FIXME did this disappear?
|
||||
|
||||
// Auth section
|
||||
let session_config = SessionConfig::default()
|
||||
.with_table_name("sessions_table")
|
||||
.with_key(Key::generate())
|
||||
.with_database_key(Key::generate())
|
||||
.with_security_mode(SecurityMode::PerSession);
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(
|
||||
Some(pool.clone().into()),
|
||||
session_config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(Some(pool.clone().into()), session_config).await.unwrap();
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("could not run SQLx migrations");
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("could not run SQLx migrations");
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(sso_auth_axum::App);
|
||||
|
||||
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(sso_auth_axum::App);
|
||||
|
||||
// We create our client using provided environment variables.
|
||||
// We create our client using provided environment variables.
|
||||
let client = oauth2::basic::BasicClient::new(
|
||||
oauth2::ClientId::new(std::env::var("G_AUTH_CLIENT_ID").expect("G_AUTH_CLIENT Env var to be set.")),
|
||||
Some(oauth2::ClientSecret::new(std::env::var("G_AUTH_SECRET").expect("G_AUTH_SECRET Env var to be set"))),
|
||||
oauth2::ClientId::new(
|
||||
std::env::var("G_AUTH_CLIENT_ID")
|
||||
.expect("G_AUTH_CLIENT Env var to be set."),
|
||||
),
|
||||
Some(oauth2::ClientSecret::new(
|
||||
std::env::var("G_AUTH_SECRET")
|
||||
.expect("G_AUTH_SECRET Env var to be set"),
|
||||
)),
|
||||
oauth2::AuthUrl::new(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
Some(
|
||||
oauth2::TokenUrl::new("https://oauth2.googleapis.com/token".to_string())
|
||||
.unwrap(),
|
||||
oauth2::TokenUrl::new(
|
||||
"https://oauth2.googleapis.com/token".to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.set_redirect_uri(oauth2::RedirectUrl::new(std::env::var("REDIRECT_URL").expect("REDIRECT_URL Env var to be set")).unwrap());
|
||||
.set_redirect_uri(
|
||||
oauth2::RedirectUrl::new(
|
||||
std::env::var("REDIRECT_URL")
|
||||
.expect("REDIRECT_URL Env var to be set"),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let app_state = AppState {
|
||||
leptos_options,
|
||||
pool: pool.clone(),
|
||||
client,
|
||||
};
|
||||
|
||||
let app_state = AppState{
|
||||
leptos_options,
|
||||
pool: pool.clone(),
|
||||
client,
|
||||
};
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/*fn_name",
|
||||
get(server_fn_handler).post(server_fn_handler),
|
||||
)
|
||||
.leptos_routes_with_handler(routes, get(leptos_routes_handler))
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(
|
||||
AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(
|
||||
Some(pool.clone()),
|
||||
)
|
||||
.with_config(auth_config),
|
||||
)
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.with_state(app_state);
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route("/api/*fn_name", get(server_fn_handler).post(server_fn_handler))
|
||||
.leptos_routes_with_handler(routes, get(leptos_routes_handler) )
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(Some(pool.clone()))
|
||||
.with_config(auth_config))
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.with_state(app_state);
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// client-only stuff for Trunk
|
||||
else {
|
||||
pub fn main() {
|
||||
// This example cannot be built as a trunk standalone CSR-only app.
|
||||
// Only the server may directly connect to the database.
|
||||
}
|
||||
}
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
use super::*;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature="ssr")]{
|
||||
use oauth2::{
|
||||
AuthorizationCode,
|
||||
TokenResponse,
|
||||
reqwest::async_http_client,
|
||||
CsrfToken,
|
||||
Scope,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
auth::{User,SqlCsrfToken},
|
||||
state::AppState
|
||||
};
|
||||
}
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr_imports {
|
||||
pub use crate::{
|
||||
auth::{ssr_imports::SqlCsrfToken, User},
|
||||
state::AppState,
|
||||
};
|
||||
pub use oauth2::{
|
||||
reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope,
|
||||
TokenResponse,
|
||||
};
|
||||
pub use serde_json::Value;
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn google_sso() -> Result<String, ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
use ssr_imports::*;
|
||||
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let pool = pool()?;
|
||||
|
||||
@@ -80,6 +80,9 @@ pub async fn handle_g_auth_redirect(
|
||||
provided_csrf: String,
|
||||
code: String,
|
||||
) -> Result<(String, u64), ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
use ssr_imports::*;
|
||||
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let pool = pool()?;
|
||||
let auth_session = auth()?;
|
||||
@@ -90,9 +93,7 @@ pub async fn handle_g_auth_redirect(
|
||||
.bind(provided_csrf)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ServerFnError::ServerError(format!("CSRF_TOKEN error : {err:?}"))
|
||||
})?;
|
||||
.map_err(|err| ServerFnError::new(format!("CSRF_TOKEN error : {err:?}")))?;
|
||||
|
||||
let token_response = oauth_client
|
||||
.exchange_code(AuthorizationCode::new(code.clone()))
|
||||
@@ -118,7 +119,7 @@ pub async fn handle_g_auth_redirect(
|
||||
.expect("email to parse to string")
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(ServerFnError::ServerError(format!(
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Response from google has status of {}",
|
||||
response.status()
|
||||
)));
|
||||
@@ -193,6 +194,8 @@ pub fn HandleGAuth() -> impl IntoView {
|
||||
|
||||
#[server]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
|
||||
let auth = auth()?;
|
||||
auth.logout_user();
|
||||
leptos_axum::redirect("/");
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::extract::FromRef;
|
||||
use leptos::LeptosOptions;
|
||||
use sqlx::SqlitePool;
|
||||
use axum::extract::FromRef;
|
||||
|
||||
/// This takes advantage of Axum's SubStates feature by deriving FromRef. This is the only way to have more than one
|
||||
/// item in Axum's State. Leptos requires you to have leptosOptions in your State struct for the leptos route handlers
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState{
|
||||
pub struct AppState {
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool,
|
||||
pub client:oauth2::basic::BasicClient,
|
||||
}
|
||||
}
|
||||
pub client: oauth2::basic::BasicClient,
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -9,12 +9,13 @@ use thiserror::Error;
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
let fallback = || view! { "Page not found." }.into_view();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<Router fallback>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
|
||||
@@ -20,7 +20,7 @@ thiserror = "1"
|
||||
axum = { version = "0.7", optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tokio = { version = "1", features = ["time"], optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"], optional = true }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -9,12 +9,13 @@ use thiserror::Error;
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
let fallback = || view! { "Page not found." }.into_view();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<Router fallback>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
|
||||
@@ -8,7 +8,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos", features = ["nightly"] }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
@@ -17,12 +16,12 @@ leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
gloo-net = { version = "0.2", features = ["http"] }
|
||||
log = "0.4"
|
||||
|
||||
# dependecies for client (enable when csr or hydrate set)
|
||||
# dependencies for client (enable when csr or hydrate set)
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_log = { version = "1", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# dependecies for server (enable when ssr set)
|
||||
# dependencies for server (enable when ssr set)
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", features = ["macros"], optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
@@ -97,7 +96,7 @@ end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -16,7 +16,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4"
|
||||
tokio = { version = "1", optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
wasm-bindgen = "0.2"
|
||||
@@ -44,7 +44,7 @@ skip_feature_sets = [["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "tailwind_axum"
|
||||
output-name = "leptos_tailwind"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Leptos with Axum + TailwindCSS Tempate
|
||||
# Leptos with Axum + TailwindCSS Template
|
||||
|
||||
This is a template demonstrating how to integrate [TailwindCSS](https://tailwindcss.com/) with the [Leptos](https://github.com/leptos-rs/leptos) web framework, Axum server, and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
|
||||
|
||||
|
||||
18
examples/tailwind_axum/package-lock.json
generated
18
examples/tailwind_axum/package-lock.json
generated
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"preline": "^1.8.0",
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
},
|
||||
@@ -104,15 +103,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -699,14 +689,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
|
||||
},
|
||||
"node_modules/preline": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/preline/-/preline-1.8.0.tgz",
|
||||
"integrity": "sha512-guttn86Fc/+AbvN9oKcr2z3zU7DL3Q5dl7nhcR4nTi5F02LXQc7WIYwgIXMR97kymCs52feiju6glXO3dUIpvA==",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.2"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
channel = "nightly-2024-01-29"
|
||||
|
||||
@@ -7,8 +7,7 @@ pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
|
||||
<Stylesheet id="leptos" href="/pkg/tailwind_axum.css"/>
|
||||
<Stylesheet id="leptos" href="/pkg/leptos_tailwind.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Router>
|
||||
<Routes>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user