Demonstrate form abstraction

This commit is contained in:
asonix 2023-04-23 16:06:31 -05:00
commit 9acf486f13
8 changed files with 2793 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
/result
/.direnv
/.envrc

2249
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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(())
}