Demonstrate form abstraction
This commit is contained in:
commit
9acf486f13
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
/result
|
||||
/.direnv
|
||||
/.envrc
|
2249
Cargo.lock
generated
Normal file
2249
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "form-testing"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
axum = "0.6.1"
|
||||
dioxus = "0.3.2"
|
||||
dioxus-liveview = { version = "0.3.0", features = ["axum"] }
|
||||
dioxus-router = "0.3.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
61
flake.lock
Normal file
61
flake.lock
Normal file
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1682181988,
|
||||
"narHash": "sha256-CYWhlNi16cjGzMby9h57gpYE59quBcsHPXiFgX4Sw5k=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6c43a3495a11e261e5f41e5d7eda2d71dae1b2fe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
34
flake.nix
Normal file
34
flake.nix
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
description = "form-testing";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = rec {
|
||||
form-testing = pkgs.callPackage ./form-testing.nix { };
|
||||
|
||||
default = form-testing;
|
||||
};
|
||||
|
||||
apps = rec {
|
||||
dev = flake-utils.lib.mkApp { drv = self.packages.${system}.pict-rs-proxy; };
|
||||
default = dev;
|
||||
};
|
||||
|
||||
devShell = with pkgs; mkShell {
|
||||
nativeBuildInputs = [ cargo cargo-outdated cargo-zigbuild clippy gcc protobuf rust-analyzer rustc rustfmt taplo ];
|
||||
|
||||
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
||||
};
|
||||
});
|
||||
}
|
27
form-testing.nix
Normal file
27
form-testing.nix
Normal file
|
@ -0,0 +1,27 @@
|
|||
{ lib
|
||||
, makeWrapper
|
||||
, nixosTests
|
||||
, protobuf
|
||||
, rustPlatform
|
||||
, stdenv
|
||||
}:
|
||||
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "form-testing";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoSha256 = "lv3ys8c2O9zCiJ6wnVrubGX8ylqh+oY7SK4eeFSjva4=";
|
||||
|
||||
PROTOC = "${protobuf}/bin/protoc";
|
||||
PROTOC_INCLUDE = "${protobuf}/include";
|
||||
|
||||
nativeBuildInputs = [ ];
|
||||
|
||||
passthru.tests = { inherit (nixosTests) form-testing; };
|
||||
|
||||
meta = with lib; {
|
||||
description = "Test for dioxus form shenanigans";
|
||||
homepage = "https://git.asonix.dog/asonix/form-testing";
|
||||
license = with licenses; [ agpl3Plus ];
|
||||
};
|
||||
}
|
188
src/form.rs
Normal file
188
src/form.rs
Normal file
|
@ -0,0 +1,188 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub(crate) trait Form {
|
||||
type FieldName: Eq + std::hash::Hash + 'static;
|
||||
|
||||
fn set(&mut self, field_name: Self::FieldName, value: String);
|
||||
|
||||
fn get(&self, field_name: &Self::FieldName) -> Option<String>;
|
||||
}
|
||||
|
||||
pub(crate) struct FormState<FieldName> {
|
||||
state: Box<dyn Form<FieldName = FieldName>>,
|
||||
errors: HashMap<FieldName, Vec<Box<dyn std::error::Error>>>,
|
||||
}
|
||||
|
||||
impl<FieldName> FormState<FieldName> {
|
||||
fn new<S: 'static>(state: S) -> Self
|
||||
where
|
||||
S: Form<FieldName = FieldName>,
|
||||
{
|
||||
Self {
|
||||
state: Box::new(state),
|
||||
errors: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<FieldName> FormState<FieldName>
|
||||
where
|
||||
FieldName: Eq + std::hash::Hash + 'static,
|
||||
{
|
||||
pub(crate) fn add_error<E: 'static>(&mut self, name: FieldName, error: E) -> &mut Self
|
||||
where
|
||||
E: std::error::Error,
|
||||
{
|
||||
self.errors.entry(name).or_default().push(Box::new(error));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn clear_errors(&mut self) -> &mut Self {
|
||||
self.errors.clear();
|
||||
self
|
||||
}
|
||||
|
||||
fn set(&mut self, name: FieldName, value: String) {
|
||||
self.state.set(name, value);
|
||||
}
|
||||
|
||||
fn get_value(&self, name: &FieldName) -> Option<String> {
|
||||
self.state.get(name)
|
||||
}
|
||||
|
||||
fn get_error(&self, name: &FieldName) -> Option<&[Box<dyn std::error::Error>]> {
|
||||
self.errors.get(name).and_then(|vec| {
|
||||
if vec.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(vec.as_slice())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[inline_props]
|
||||
#[allow(non_snake_case)]
|
||||
fn InputErrors<'a, FieldName>(cx: Scope, name: &'a FieldName) -> Element
|
||||
where
|
||||
FieldName: Eq + std::hash::Hash + 'static,
|
||||
{
|
||||
let errors = use_shared_state::<FormState<FieldName>>(cx);
|
||||
|
||||
if let Some(errors) = errors {
|
||||
let errors = errors.read();
|
||||
if let Some(errors) = errors.get_error(name) {
|
||||
return cx.render(rsx! {
|
||||
div {
|
||||
class: "text-input--errors",
|
||||
errors.iter().map(|error| rsx! {
|
||||
span {
|
||||
class: "text-input--errors--error",
|
||||
"{error}"
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[inline_props]
|
||||
#[allow(non_snake_case)]
|
||||
pub(crate) fn TextInput<'a, FieldName>(cx: Scope, name: FieldName, label: Element<'a>) -> Element
|
||||
where
|
||||
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
|
||||
{
|
||||
cx.render(rsx! {
|
||||
Input { kind: "text", name: name.clone(), label: label.clone() }
|
||||
})
|
||||
}
|
||||
|
||||
#[inline_props]
|
||||
#[allow(non_snake_case)]
|
||||
pub(crate) fn PasswordInput<'a, FieldName>(
|
||||
cx: Scope,
|
||||
name: FieldName,
|
||||
label: Element<'a>,
|
||||
) -> Element
|
||||
where
|
||||
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
|
||||
{
|
||||
cx.render(rsx! {
|
||||
Input { kind: "password", name: name.clone(), label: label.clone() }
|
||||
})
|
||||
}
|
||||
|
||||
#[inline_props]
|
||||
#[allow(non_snake_case)]
|
||||
pub(crate) fn Input<'a, FieldName>(
|
||||
cx: Scope,
|
||||
kind: &'static str,
|
||||
name: FieldName,
|
||||
label: Element<'a>,
|
||||
) -> Element
|
||||
where
|
||||
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
|
||||
{
|
||||
cx.render(rsx! {
|
||||
label {
|
||||
class: "text-input",
|
||||
span {
|
||||
class: "text-input--label",
|
||||
label,
|
||||
},
|
||||
match use_shared_state::<FormState<FieldName>>(cx) {
|
||||
Some(values) => {
|
||||
let value = values.read().get_value(&name).unwrap_or_else(|| String::new());
|
||||
|
||||
rsx! {
|
||||
input {
|
||||
name: "{name}",
|
||||
r#type: "{kind}",
|
||||
value: "{value}",
|
||||
oninput: move |event| values.write().set(name.clone(), event.value.clone()),
|
||||
},
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
input {
|
||||
name: "{name}",
|
||||
r#type: "{kind}",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputErrors { name: name, }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl<T> Form for UseRef<T>
|
||||
where
|
||||
T: Form,
|
||||
{
|
||||
type FieldName = T::FieldName;
|
||||
|
||||
fn set(&mut self, field_name: Self::FieldName, value: String) {
|
||||
self.with_mut(|form| form.set(field_name, value));
|
||||
}
|
||||
|
||||
fn get(&self, field_name: &Self::FieldName) -> Option<String> {
|
||||
self.with(|form| form.get(field_name))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn use_form<FieldName, State>(
|
||||
cx: &ScopeState,
|
||||
form: UseRef<State>,
|
||||
) -> UseSharedState<'_, FormState<FieldName>>
|
||||
where
|
||||
State: Form<FieldName = FieldName> + Default + 'static,
|
||||
{
|
||||
use_shared_state_provider(cx, move || FormState::<FieldName>::new(form));
|
||||
|
||||
use_shared_state(cx).expect("Just set up")
|
||||
}
|
217
src/main.rs
Normal file
217
src/main.rs
Normal file
|
@ -0,0 +1,217 @@
|
|||
use std::{hash::Hash, net::SocketAddr};
|
||||
|
||||
use axum::{
|
||||
extract::WebSocketUpgrade,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_liveview::LiveViewPool;
|
||||
use dioxus_router::{Link, Redirect, Route, Router};
|
||||
|
||||
mod form;
|
||||
|
||||
use form::{use_form, PasswordInput, TextInput};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Form {
|
||||
username: String,
|
||||
password: String,
|
||||
password_confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum Field {
|
||||
Username,
|
||||
Password,
|
||||
ConfirmPassword,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Error {
|
||||
UsernameBlank,
|
||||
UsernameTooShort,
|
||||
PasswordBlank,
|
||||
PasswordTooShort,
|
||||
PasswordMismatch,
|
||||
}
|
||||
|
||||
impl form::Form for Form {
|
||||
type FieldName = Field;
|
||||
|
||||
fn set(&mut self, field_name: Self::FieldName, value: String) {
|
||||
match field_name {
|
||||
Field::Username => {
|
||||
self.username = value;
|
||||
}
|
||||
Field::Password => {
|
||||
self.password = value;
|
||||
}
|
||||
Field::ConfirmPassword => {
|
||||
self.password_confirmation = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, field_name: &Self::FieldName) -> Option<String> {
|
||||
Some(match field_name {
|
||||
Field::Username => self.username.clone(),
|
||||
Field::Password => self.password.clone(),
|
||||
Field::ConfirmPassword => self.password_confirmation.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Field {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Username => write!(f, "username"),
|
||||
Self::Password => write!(f, "password"),
|
||||
Self::ConfirmPassword => write!(f, "confirm-password"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UsernameBlank => write!(f, "Username is required"),
|
||||
Self::UsernameTooShort => write!(f, "Username is too short"),
|
||||
Self::PasswordBlank => write!(f, "Password is required"),
|
||||
Self::PasswordTooShort => write!(f, "Password is too short"),
|
||||
Self::PasswordMismatch => write!(f, "Passwords must match"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
fn Home(cx: Scope) -> Element {
|
||||
let form = use_ref(cx, Form::default);
|
||||
let form_state = use_form(cx, form.clone());
|
||||
|
||||
cx.render(rsx! {
|
||||
form {
|
||||
onsubmit: move |_| {
|
||||
let form = form.read();
|
||||
let mut form_state = form_state.write();
|
||||
form_state.clear_errors();
|
||||
|
||||
if form.username.is_empty() {
|
||||
form_state.add_error(Field::Username, Error::UsernameBlank);
|
||||
}
|
||||
if form.username.len() < 5 {
|
||||
form_state.add_error(Field::Username, Error::UsernameTooShort);
|
||||
}
|
||||
|
||||
if form.password.is_empty() {
|
||||
form_state.add_error(Field::Password, Error::PasswordBlank);
|
||||
}
|
||||
if form.password.len() < 8 {
|
||||
form_state.add_error(Field::Password, Error::PasswordTooShort);
|
||||
}
|
||||
|
||||
if form.password != form.password_confirmation {
|
||||
form_state.add_error(Field::ConfirmPassword, Error::PasswordMismatch);
|
||||
}
|
||||
|
||||
println!("{:?}", form);
|
||||
},
|
||||
|
||||
TextInput {
|
||||
name: Field::Username,
|
||||
label: cx.render(rsx! { "Username" }),
|
||||
},
|
||||
|
||||
PasswordInput {
|
||||
name: Field::Password,
|
||||
label: cx.render(rsx! { "Password" }),
|
||||
},
|
||||
|
||||
PasswordInput {
|
||||
name: Field::ConfirmPassword,
|
||||
label: cx.render(rsx! { "Confirm Password" }),
|
||||
},
|
||||
|
||||
input {
|
||||
r#type: "submit",
|
||||
value: "Sign Up"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn application(cx: Scope) -> Element {
|
||||
cx.render(rsx! {
|
||||
Router {
|
||||
nav {
|
||||
div {
|
||||
Link { to: "/posts", "Home" },
|
||||
},
|
||||
},
|
||||
Route { to: "/posts", Home {} },
|
||||
Redirect { from: "", to: "/posts" },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn spa(addr: SocketAddr) -> Html<String> {
|
||||
Html(format!(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dioxus LiveView with Axum</title>
|
||||
<style>
|
||||
* {{
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #333;
|
||||
color: #f5f5f5;
|
||||
font-family: sans;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body> <div id="main"></div> </body>
|
||||
{glue}
|
||||
</html>
|
||||
"#,
|
||||
// Create the glue code to connect to the WebSocket on the "/ws" route
|
||||
glue = dioxus_liveview::interpreter_glue(&format!("ws://{addr}/ws"))
|
||||
))
|
||||
}
|
||||
|
||||
async fn websocket(ws: WebSocketUpgrade, view: LiveViewPool) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
// When the WebSocket is upgraded, launch the LiveView with the app component
|
||||
_ = view
|
||||
.launch(dioxus_liveview::axum_socket(socket), application)
|
||||
.await;
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], 3030).into();
|
||||
|
||||
let view = dioxus_liveview::LiveViewPool::new();
|
||||
|
||||
let app = Router::new()
|
||||
// The root route contains the glue code to connect to the WebSocket
|
||||
.route("/", get(move || spa(addr)))
|
||||
// The WebSocket route is what Dioxus uses to communicate with the browser
|
||||
.route("/ws", get(move |sock| websocket(sock, view)));
|
||||
|
||||
println!("Listening on http://{addr}");
|
||||
|
||||
axum::Server::bind(&addr.to_string().parse()?)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in a new issue