app,server: Implement selection of household

This commit is contained in:
traxys 2023-05-29 15:35:30 +02:00
parent dd266dccfd
commit 1eb815ada9
8 changed files with 284 additions and 34 deletions

1
Cargo.lock generated
View file

@ -75,6 +75,7 @@ dependencies = [
"gloo-net", "gloo-net",
"gloo-storage", "gloo-storage",
"gloo-utils", "gloo-utils",
"itertools",
"log", "log",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -17,7 +17,7 @@ pub struct LoginResponse {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct EmptyResponse {} pub struct EmptyResponse {}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Clone)]
pub struct Household { pub struct Household {
pub name: String, pub name: String,
pub members: Vec<Uuid>, pub members: Vec<Uuid>,

View file

@ -12,6 +12,7 @@ console_log = { version = "1.0.0", features = ["color"] }
gloo-net = "0.2.6" gloo-net = "0.2.6"
gloo-storage = "0.2.2" gloo-storage = "0.2.2"
gloo-utils = "0.1.6" gloo-utils = "0.1.6"
itertools = "0.10.5"
log = "0.4.17" log = "0.4.17"
serde = { version = "1.0.163", features = ["derive"] } serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96" serde_json = "1.0.96"

View file

@ -10,6 +10,7 @@
/> />
<link data-trunk rel="css" href="static/style.css" /> <link data-trunk rel="css" href="static/style.css" />
<link data-trunk rel="copy-file" href="static/login.css" /> <link data-trunk rel="copy-file" href="static/login.css" />
<link data-trunk rel="copy-file" href="static/household_selection.css" />
</head> </head>
<body> <body>
<main></main> <main></main>

View file

@ -1,11 +1,14 @@
use api::{LoginRequest, LoginResponse}; use api::{
CreateHouseholdRequest, CreateHouseholdResponse, Household, LoginRequest, LoginResponse,
};
use gloo_storage::{errors::StorageError, LocalStorage, Storage}; use gloo_storage::{errors::StorageError, LocalStorage, Storage};
use log::Level; use log::Level;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use wasm_bindgen::JsCast; use wasm_bindgen::{prelude::*, JsCast};
use web_sys::HtmlInputElement; use web_sys::HtmlInputElement;
use yew::prelude::*; use yew::{prelude::*, suspense::use_future};
use itertools::Itertools;
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::sidebar::RegaladeSidebar; use crate::sidebar::RegaladeSidebar;
@ -53,15 +56,21 @@ struct HouseholdInfo {
name: String, name: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct LoginInfo {
token: String,
name: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct RegaladeGlobalState { struct RegaladeGlobalState {
token: AttrValue, token: LoginInfo,
household: HouseholdInfo, household: HouseholdInfo,
} }
impl RegaladeGlobalState { impl RegaladeGlobalState {
pub fn get_or_navigate(navigator: Navigator) -> Option<Self> { pub fn get_or_navigate(navigator: Navigator) -> Option<Self> {
let token = match LocalStorage::get::<String>("token") { let token = match LocalStorage::get::<LoginInfo>("token") {
Ok(v) => v, Ok(v) => v,
Err(StorageError::KeyNotFound(_)) => { Err(StorageError::KeyNotFound(_)) => {
navigator.push(&Route::Login); navigator.push(&Route::Login);
@ -79,14 +88,11 @@ impl RegaladeGlobalState {
Err(e) => unreachable!("Could not get household: {e:?}"), Err(e) => unreachable!("Could not get household: {e:?}"),
}; };
Some(Self { Some(Self { token, household })
token: token.into(),
household,
})
} }
pub fn get() -> Self { pub fn get() -> Self {
let token = match LocalStorage::get::<String>("token") { let token = match LocalStorage::get::<LoginInfo>("token") {
Ok(v) => v, Ok(v) => v,
Err(e) => unreachable!("Could not get token: {e:?}"), Err(e) => unreachable!("Could not get token: {e:?}"),
}; };
@ -96,10 +102,7 @@ impl RegaladeGlobalState {
Err(e) => unreachable!("Could not get household: {e:?}"), Err(e) => unreachable!("Could not get household: {e:?}"),
}; };
Self { Self { token, household }
token: token.into(),
household,
}
} }
} }
@ -112,12 +115,12 @@ struct GlobalStateRedirectorProps {
#[function_component] #[function_component]
fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html { fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html {
let navigator = use_navigator().unwrap(); let navigator = use_navigator().unwrap();
let state = use_state(|| RegaladeGlobalState::get_or_navigate(navigator)); let s = RegaladeGlobalState::get_or_navigate(navigator);
match &*state { match s {
Some(state) => { Some(_) => {
html! { html! {
<RegaladeSidebar current={props.route} household={state.household.clone()}> <RegaladeSidebar current={props.route}>
{ for props.children.iter() } { for props.children.iter() }
</RegaladeSidebar> </RegaladeSidebar>
} }
@ -126,21 +129,233 @@ fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html {
} }
} }
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
let rsp = gloo_net::http::Request::post(api!("household"))
.json(&CreateHouseholdRequest { name })?
.header("Authorization", &format!("Bearer {token}"))
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
let rsp: CreateHouseholdResponse = rsp.json().await?;
Ok(rsp.id)
}
#[wasm_bindgen(js_namespace = bootstrap)]
extern "C" {
type Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")]
fn get_instance(selector: &str) -> Modal;
#[wasm_bindgen(method)]
fn hide(this: &Modal);
}
async fn fetch_households(token: String) -> anyhow::Result<api::Households> {
let rsp = gloo_net::http::Request::get(api!("household"))
.header("Authorization", &format!("Bearer {token}"))
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
let rsp: api::Households = rsp.json().await?;
Ok(rsp)
}
#[function_component]
fn HouseholdListSelect() -> HtmlResult {
let token = use_state(|| match LocalStorage::get::<LoginInfo>("token") {
Ok(v) => v,
Err(e) => unreachable!("Need to be logged in to list households: {e:?}"),
});
let households = use_future(move || fetch_households(token.token.to_string()))?;
let navigator = use_navigator().unwrap();
let mk_household = |(id, info): (_, Household)| {
let name = info.name.clone();
let nav = navigator.clone();
let onclick = Callback::from(move |_| {
log::info!("Clicked {id}");
if let Err(e) = LocalStorage::set(
"household",
HouseholdInfo {
id,
name: name.clone(),
},
) {
log::error!("Could not select household: {e:?}");
return;
}
nav.push(&Route::Index);
});
html! {<>
<button class={classes!("btn", "btn-secondary", "m-1")} {onclick}>
{&info.name}
</button>
<br />
</>}
};
Ok(match &*households {
Ok(households) => html! {
{ for households
.households
.clone()
.into_iter()
.sorted_by_key(|(_,i)| i.name.clone())
.map(mk_household)
}
},
Err(e) => {
log::error!("Could not fetch households: {e:?}");
html! {
{"<ERROR>"}
}
}
})
}
#[function_component] #[function_component]
fn HouseholdSelection() -> Html { fn HouseholdSelection() -> Html {
let token = use_state(|| match LocalStorage::get::<String>("token") { let token = use_state(|| match LocalStorage::get::<LoginInfo>("token") {
Ok(v) => Some(v), Ok(v) => Some(v),
Err(StorageError::KeyNotFound(_)) => None, Err(StorageError::KeyNotFound(_)) => None,
Err(e) => unreachable!("Could not get household: {e:?}"), Err(e) => unreachable!("Could not get household: {e:?}"),
}); });
let error = use_state(|| None::<String>);
let navigator = use_navigator().unwrap();
let err = error.clone();
let tok = token.clone();
let onsubmit = Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let document = gloo_utils::document();
let token = tok.as_ref().unwrap().to_owned();
let name: HtmlInputElement = document
.get_element_by_id("newHsName")
.unwrap()
.dyn_into()
.expect("newHsName is not an input element");
let name = name.value();
let err = err.clone();
let navigator = navigator.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_new_household(token.token, name.clone()).await {
Ok(id) => {
let household_info = HouseholdInfo { name, id };
if let Err(e) = LocalStorage::set("household", household_info) {
log::error!("Could not switch to new household: {e:?}")
}
let modal = Modal::get_instance("#newHsModal");
modal.hide();
navigator.push(&Route::Index);
err.set(None);
}
Err(e) => {
err.set(Some(format!("Could not create: {e:?}")));
}
}
});
});
let fallback = html! { {"Loading..."} };
match &*token { match &*token {
None => html! { None => html! {
<Redirect<Route> to={Route::Login} /> <Redirect<Route> to={Route::Login} />
}, },
Some(_) => html! { Some(_) => html! {<>
{"Household Selection"} <link href="/household_selection.css" rel="stylesheet" />
}, <div class={classes!("col-sm-3", "m-auto", "p-2", "text-center", "border", "rounded")}>
<h1 class="h3">{"Available"}</h1>
<hr />
<Suspense {fallback}>
<HouseholdListSelect />
</Suspense>
<hr />
<button
class={classes!("btn", "btn-lg", "btn-primary")}
data-bs-toggle="modal"
data-bs-target="#newHsModal"
>
{"New household"}
</button>
<div
class={classes!("modal", "fade")}
id="newHsModal"
tabindex="-1"
aria-labelledby="newHsModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class={classes!("modal-title", "fs-5")} id="newHsModalLabel">
{"Create a household"}
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
>
</button>
</div>
<div class="modal-body">
<form id="newHsForm" {onsubmit}>
if let Some(err) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{err}
</div>
}
<div class="form-floating">
<input
id="newHsName"
class={classes!("form-control")}
placeholder="Household name"
/>
<label for="newHsName">{"Household name"}</label>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class={classes!("btn", "btn-secondary")}
data-bs-dismiss="modal"
>
{"Cancel"}
</button>
<button
type="submit"
class={classes!("btn", "btn-primary")}
form="newHsForm"
>
{"Create"}
</button>
</div>
</div>
</div>
</div>
</div>
</>},
} }
} }
@ -170,7 +385,10 @@ fn switch(route: Route) -> Html {
async fn do_login(username: String, password: String) -> anyhow::Result<()> { async fn do_login(username: String, password: String) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::post(api!("login")) let rsp = gloo_net::http::Request::post(api!("login"))
.json(&LoginRequest { username, password })? .json(&LoginRequest {
username: username.clone(),
password,
})?
.send() .send()
.await?; .await?;
@ -182,7 +400,13 @@ async fn do_login(username: String, password: String) -> anyhow::Result<()> {
let rsp: LoginResponse = rsp.json().await?; let rsp: LoginResponse = rsp.json().await?;
LocalStorage::set("token", rsp.token)?; LocalStorage::set(
"token",
LoginInfo {
token: rsp.token,
name: username,
},
)?;
Ok(()) Ok(())
} }

View file

@ -1,5 +1,6 @@
use crate::{RegaladeGlobalState, Route};
use yew::prelude::*; use yew::prelude::*;
use crate::{Route, HouseholdInfo}; use yew_router::prelude::*;
#[derive(PartialEq)] #[derive(PartialEq)]
struct MenuEntry { struct MenuEntry {
@ -12,12 +13,13 @@ struct MenuEntry {
struct SidebarProps { struct SidebarProps {
entries: Vec<MenuEntry>, entries: Vec<MenuEntry>,
current: Route, current: Route,
household: HouseholdInfo,
children: Children, children: Children,
} }
#[function_component] #[function_component]
fn Sidebar(props: &SidebarProps) -> Html { fn Sidebar(props: &SidebarProps) -> Html {
let global_state = use_state(RegaladeGlobalState::get);
html! { html! {
<div class="container-fluid"> <div class="container-fluid">
<div class={classes!("row", "flex-nowrap")}> <div class={classes!("row", "flex-nowrap")}>
@ -100,12 +102,19 @@ fn Sidebar(props: &SidebarProps) -> Html {
> >
<i class={classes!("fs-4", "bi-house-door-fill")}></i> <i class={classes!("fs-4", "bi-house-door-fill")}></i>
<strong class={classes!("ms-2", "d-none", "d-sm-inline")}> <strong class={classes!("ms-2", "d-none", "d-sm-inline")}>
{&props.household.name} {format!("{} ({})", global_state.household.name, global_state.token.name)}
</strong> </strong>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<hr /> <hr />
<li><a class="dropdown-item" href="#">{"New household"}</a></li> <li>
<Link<Route>
classes={classes!("dropdown-item")}
to={Route::HouseholdSelect}
>
{"Change household"}
</Link<Route>>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -121,7 +130,6 @@ fn Sidebar(props: &SidebarProps) -> Html {
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub(crate) struct RegaladeSidebarProps { pub(crate) struct RegaladeSidebarProps {
pub(crate) current: Route, pub(crate) current: Route,
pub(crate) household: HouseholdInfo,
pub(crate) children: Children, pub(crate) children: Children,
} }
@ -141,9 +149,8 @@ pub(crate) fn RegaladeSidebar(props: &RegaladeSidebarProps) -> Html {
]; ];
html! { html! {
<Sidebar {entries} current={props.current} household={props.household.clone()}> <Sidebar {entries} current={props.current}>
{ for props.children.iter() } { for props.children.iter() }
</Sidebar> </Sidebar>
} }
} }

View file

@ -0,0 +1,12 @@
html,
body,
main {
height: 100%;
}
main {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}

View file

@ -5,7 +5,11 @@ use axum::{
async_trait, async_trait,
extract::{FromRef, FromRequestParts, State}, extract::{FromRef, FromRequestParts, State},
headers::{authorization::Bearer, Authorization}, headers::{authorization::Bearer, Authorization},
http::{header::CONTENT_TYPE, request::Parts, HeaderValue, Method, StatusCode}, http::{
header::{AUTHORIZATION, CONTENT_TYPE},
request::Parts,
HeaderValue, Method, StatusCode,
},
response::IntoResponse, response::IntoResponse,
routing::{get, post, put}, routing::{get, post, put},
Json, Router, TypedHeader, Json, Router, TypedHeader,
@ -141,7 +145,7 @@ pub(crate) fn router(api_allowed: Option<HeaderValue>) -> Router<AppState> {
}; };
let cors_base = CorsLayer::new() let cors_base = CorsLayer::new()
.allow_headers([CONTENT_TYPE]) .allow_headers([CONTENT_TYPE, AUTHORIZATION])
.allow_origin(origin); .allow_origin(origin);
let mk_service = |m: Vec<Method>| cors_base.clone().allow_methods(m); let mk_service = |m: Vec<Method>| cors_base.clone().allow_methods(m);