diff --git a/examples/trunk_and_server_fns/.gitignore b/examples/trunk_and_server_fns/.gitignore new file mode 100644 index 0000000000..5d70b96388 --- /dev/null +++ b/examples/trunk_and_server_fns/.gitignore @@ -0,0 +1 @@ +.leptos.kdl \ No newline at end of file diff --git a/examples/trunk_and_server_fns/Cargo.toml b/examples/trunk_and_server_fns/Cargo.toml new file mode 100644 index 0000000000..3bf6c449a7 --- /dev/null +++ b/examples/trunk_and_server_fns/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "todo_app_sqlite_csr" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +name = "front" + +[dependencies] +actix-files = { version = "0.6.2", optional = true } +actix-web = { version = "4.2.1", optional = true, features = ["macros"] } +console_log = "1.0.0" +console_error_panic_hook = "0.1.7" +serde = { version = "1.0.152", features = ["derive"] } +futures = "0.3.25" +leptos = { path = "../../leptos", features = ["nightly"] } +leptos_actix = { path = "../../integrations/actix", optional = true } +leptos_meta = { path = "../../meta", features = ["nightly"] } +leptos_router = { path = "../../router", features = ["nightly"] } +log = "0.4.17" +sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "sqlite"], optional = true } +wasm-bindgen = "0.2" +tokio = { version = "1", features = ["rt", "time"], optional = true } + +[features] +server = [ + "dep:actix-web", + "dep:sqlx", + "leptos_actix", + "dep:tokio", +] + +[package.metadata.cargo-all-features] +denylist = ["actix-web", "leptos_actix", "sqlx"] \ No newline at end of file diff --git a/examples/trunk_and_server_fns/LICENSE b/examples/trunk_and_server_fns/LICENSE new file mode 100644 index 0000000000..77d5625cb3 --- /dev/null +++ b/examples/trunk_and_server_fns/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Greg Johnston + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/trunk_and_server_fns/Makefile.toml b/examples/trunk_and_server_fns/Makefile.toml new file mode 100644 index 0000000000..4caca105f5 --- /dev/null +++ b/examples/trunk_and_server_fns/Makefile.toml @@ -0,0 +1,12 @@ +extend = [ + { path = "../cargo-make/main.toml" }, + { path = "../cargo-make/cargo-leptos-webdriver-test.toml" }, +] + +[env] +CLIENT_PROCESS_NAME = "todo_app_sqlite" + +[tasks.test-ui] +cwd = "./e2e" +command = "cargo" +args = ["make", "test-ui", "${@}"] diff --git a/examples/trunk_and_server_fns/README.md b/examples/trunk_and_server_fns/README.md new file mode 100644 index 0000000000..6a3ca980d9 --- /dev/null +++ b/examples/trunk_and_server_fns/README.md @@ -0,0 +1,15 @@ +# Leptos Todo App Without SSR + +This is a minimal example of how to use Leptos' server functions without using Server Side Rendering or cargo-leptos. + +To run the app, first start the server. + +``` +cargo run --features=server +``` + +Then use [Trunk](https://trunkrs.dev/) to build and serve the web assets: + +``` +trunk serve +``` diff --git a/examples/trunk_and_server_fns/Todos.db b/examples/trunk_and_server_fns/Todos.db new file mode 100644 index 0000000000..ec85d2b07f Binary files /dev/null and b/examples/trunk_and_server_fns/Todos.db differ diff --git a/examples/trunk_and_server_fns/Trunk.toml b/examples/trunk_and_server_fns/Trunk.toml new file mode 100644 index 0000000000..ba29914c0f --- /dev/null +++ b/examples/trunk_and_server_fns/Trunk.toml @@ -0,0 +1,15 @@ +[build] +# The index HTML file to drive the bundling process. +target = "index.html" + +[serve] +address = "127.0.0.1" +port = 9000 + +[watch] +watch = ["./"] + +[[proxy]] +# This proxy specifies only the backend, which is the only required field. In this example, +# request URIs are not modified when proxied. +backend = "http://localhost:3000/api/" \ No newline at end of file diff --git a/examples/trunk_and_server_fns/e2e/Cargo.toml b/examples/trunk_and_server_fns/e2e/Cargo.toml new file mode 100644 index 0000000000..cd11a0618b --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "todo_app_sqlite_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 diff --git a/examples/trunk_and_server_fns/e2e/Makefile.toml b/examples/trunk_and_server_fns/e2e/Makefile.toml new file mode 100644 index 0000000000..cd76be24d5 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/Makefile.toml @@ -0,0 +1,20 @@ +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", + "${@}", +] diff --git a/examples/trunk_and_server_fns/e2e/README.md b/examples/trunk_and_server_fns/e2e/README.md new file mode 100644 index 0000000000..181fe1ed21 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/README.md @@ -0,0 +1,33 @@ +# 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 | +| [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 +``` diff --git a/examples/trunk_and_server_fns/e2e/features/add_todo.feature b/examples/trunk_and_server_fns/e2e/features/add_todo.feature new file mode 100644 index 0000000000..1a92258676 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/features/add_todo.feature @@ -0,0 +1,17 @@ +@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 + + # @allow.skipped + @add_todo-style + Scenario: Should see the pending todo + When I add a todo as Buy Oranges + Then I see the pending todo diff --git a/examples/trunk_and_server_fns/e2e/features/delete_todo.feature b/examples/trunk_and_server_fns/e2e/features/delete_todo.feature new file mode 100644 index 0000000000..3c1e743d26 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/features/delete_todo.feature @@ -0,0 +1,18 @@ +@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. \ No newline at end of file diff --git a/examples/trunk_and_server_fns/e2e/features/open_app.feature b/examples/trunk_and_server_fns/e2e/features/open_app.feature new file mode 100644 index 0000000000..f4b4e39529 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/features/open_app.feature @@ -0,0 +1,12 @@ +@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 \ No newline at end of file diff --git a/examples/trunk_and_server_fns/e2e/tests/app_suite.rs b/examples/trunk_and_server_fns/e2e/tests/app_suite.rs new file mode 100644 index 0000000000..5c56b6aca8 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/app_suite.rs @@ -0,0 +1,14 @@ +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(()) +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/action.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/action.rs new file mode 100644 index 0000000000..79b5c685ee --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/action.rs @@ -0,0 +1,60 @@ +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(()) +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/check.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/check.rs new file mode 100644 index 0000000000..f43629b95c --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/check.rs @@ -0,0 +1,57 @@ +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(()) +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/find.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/find.rs new file mode 100644 index 0000000000..228fce6a28 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/find.rs @@ -0,0 +1,63 @@ +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 { + 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 { + 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 { + if let Ok(element) = + client.wait().for_element(Locator::Css(".pending")).await + { + return Some(element); + } + + None +} + +pub async fn todos(client: &Client) -> Vec { + let todos = client + .find_all(Locator::Css("li")) + .await + .expect("Todo List not found"); + + todos +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/mod.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/mod.rs new file mode 100644 index 0000000000..72b1bd65e4 --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/mod.rs @@ -0,0 +1,4 @@ +pub mod action; +pub mod check; +pub mod find; +pub mod world; diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/world/action_steps.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/action_steps.rs new file mode 100644 index 0000000000..5c4e062dba --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/action_steps.rs @@ -0,0 +1,57 @@ +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(()) +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/world/check_steps.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/check_steps.rs new file mode 100644 index 0000000000..3e51215dba --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/check_steps.rs @@ -0,0 +1,67 @@ +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(()) +} diff --git a/examples/trunk_and_server_fns/e2e/tests/fixtures/world/mod.rs b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/mod.rs new file mode 100644 index 0000000000..2263740c8a --- /dev/null +++ b/examples/trunk_and_server_fns/e2e/tests/fixtures/world/mod.rs @@ -0,0 +1,39 @@ +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:9000"; + +#[derive(Debug, World)] +#[world(init = Self::new)] +pub struct AppWorld { + pub client: Client, +} + +impl AppWorld { + async fn new() -> Result { + let webdriver_client = build_client().await?; + + Ok(Self { + client: webdriver_client, + }) + } +} + +async fn build_client() -> Result { + 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) +} diff --git a/examples/trunk_and_server_fns/index.html b/examples/trunk_and_server_fns/index.html new file mode 100644 index 0000000000..550446b193 --- /dev/null +++ b/examples/trunk_and_server_fns/index.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/trunk_and_server_fns/migrations/20221118172000_create_todo_table.sql b/examples/trunk_and_server_fns/migrations/20221118172000_create_todo_table.sql new file mode 100644 index 0000000000..5bc74dcf27 --- /dev/null +++ b/examples/trunk_and_server_fns/migrations/20221118172000_create_todo_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS todos +( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR, + completed BOOLEAN +); \ No newline at end of file diff --git a/examples/trunk_and_server_fns/public/favicon.ico b/examples/trunk_and_server_fns/public/favicon.ico new file mode 100644 index 0000000000..2ba8527cb1 Binary files /dev/null and b/examples/trunk_and_server_fns/public/favicon.ico differ diff --git a/examples/trunk_and_server_fns/rust-toolchain.toml b/examples/trunk_and_server_fns/rust-toolchain.toml new file mode 100644 index 0000000000..055e912d2e --- /dev/null +++ b/examples/trunk_and_server_fns/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2024-01-29" diff --git a/examples/trunk_and_server_fns/src/lib.rs b/examples/trunk_and_server_fns/src/lib.rs new file mode 100644 index 0000000000..ff6eb8ecd4 --- /dev/null +++ b/examples/trunk_and_server_fns/src/lib.rs @@ -0,0 +1 @@ +pub mod todo; diff --git a/examples/trunk_and_server_fns/src/main.rs b/examples/trunk_and_server_fns/src/main.rs new file mode 100644 index 0000000000..f744342bed --- /dev/null +++ b/examples/trunk_and_server_fns/src/main.rs @@ -0,0 +1,32 @@ +mod todo; + +#[cfg(feature = "server")] +#[actix_web::main] +async fn main() -> std::io::Result<()> { + use self::todo::server::*; + use actix_web::*; + + let mut conn = db().await.expect("couldn't connect to DB"); + sqlx::migrate!() + .run(&mut conn) + .await + .expect("could not run SQLx migrations"); + + let addr = "127.0.0.1:3000"; + + HttpServer::new(move || { + App::new().route("/api/{tail:.*}", leptos_actix::handle_server_fns()) + }) + .bind(addr)? + .run() + .await +} + +#[cfg(not(feature = "server"))] +pub fn main() { + use crate::todo::*; + console_error_panic_hook::set_once(); + _ = console_log::init_with_level(log::Level::Debug); + + leptos::mount_to_body(TodoApp); +} diff --git a/examples/trunk_and_server_fns/src/todo.rs b/examples/trunk_and_server_fns/src/todo.rs new file mode 100644 index 0000000000..745186dbee --- /dev/null +++ b/examples/trunk_and_server_fns/src/todo.rs @@ -0,0 +1,199 @@ +use leptos::*; +use leptos_meta::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "server", derive(sqlx::FromRow))] +pub struct Todo { + id: u16, + title: String, + completed: bool, +} + +#[cfg(feature = "server")] +pub mod server { + pub use actix_web::HttpRequest; + pub use leptos::ServerFnError; + pub use sqlx::{Connection, SqliteConnection}; + + pub async fn db() -> Result { + Ok(SqliteConnection::connect("sqlite:Todos.db").await?) + } +} + +/// This is an example of a server function using an alternative CBOR encoding. Both the function arguments being sent +/// to the server and the server response will be encoded with CBOR. Good for binary data that doesn't encode well via the default methods +#[server(encoding = "Cbor")] +pub async fn get_todos() -> Result, ServerFnError> { + use self::server::*; + + // this is just an example of how to access server context injected in the handlers + let req = use_context::(); + + if let Some(req) = req { + println!("req.path = {:#?}", req.path()); + } + use futures::TryStreamExt; + + let mut conn = db().await?; + + let mut todos = Vec::new(); + let mut rows = + sqlx::query_as::<_, Todo>("SELECT * FROM todos").fetch(&mut conn); + while let Some(row) = rows.try_next().await? { + todos.push(row); + } + + Ok(todos) +} + +#[server] +pub async fn add_todo(title: String) -> Result<(), ServerFnError> { + use self::server::*; + + let mut conn = db().await?; + + // fake API delay + std::thread::sleep(std::time::Duration::from_millis(1250)); + + match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)") + .bind(title) + .execute(&mut conn) + .await + { + Ok(_row) => Ok(()), + Err(e) => Err(ServerFnError::ServerError(e.to_string())), + } +} + +// The struct name and path prefix arguments are optional. +#[server] +pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> { + use self::server::*; + + let mut conn = db().await?; + + Ok(sqlx::query("DELETE FROM todos WHERE id = $1") + .bind(id) + .execute(&mut conn) + .await + .map(|_| ())?) +} + +#[component] +pub fn TodoApp() -> impl IntoView { + provide_meta_context(); + + view! { + + + +
+

"My Tasks"

+
+
+ + + +
+
+ } +} + +#[component] +pub fn Todos() -> impl IntoView { + let add_todo = create_server_multi_action::(); + let delete_todo = create_server_action::(); + let submissions = add_todo.submissions(); + + // list of todos is loaded from the server in reaction to changes + let todos = create_resource( + move || (add_todo.version().get(), delete_todo.version().get()), + move |_| get_todos(), + ); + + view! { +
+ + + + + "Loading..."

}> + {move || { + let existing_todos = { + move || { + todos.get() + .map(move |todos| match todos { + Err(e) => { + view! {
"Server Error: " {e.to_string()}
}.into_view() + } + Ok(todos) => { + if todos.is_empty() { + view! {

"No tasks were found."

}.into_view() + } else { + todos + .into_iter() + .map(move |todo| { + view! { + +
  • + {todo.title} + + + + +
  • + } + }) + .collect_view() + } + } + }) + .unwrap_or_default() + } + }; + + let pending_todos = move || { + submissions + .get() + .into_iter() + .filter(|submission| submission.pending().get()) + .map(|submission| { + view! { + +
  • {move || submission.input.get().map(|data| data.title) }
  • + } + }) + .collect_view() + }; + + view! { + +
      + {existing_todos} + {pending_todos} +
    + } + } + } +
    +
    + } +} diff --git a/examples/trunk_and_server_fns/style.css b/examples/trunk_and_server_fns/style.css new file mode 100644 index 0000000000..152dd11327 --- /dev/null +++ b/examples/trunk_and_server_fns/style.css @@ -0,0 +1,3 @@ +.pending { + color: purple; +} \ No newline at end of file