persistent websocket

This commit is contained in:
Asger Juul Brunshøj 2023-06-13 11:27:00 +02:00
parent 70d99b0c6b
commit 78999174ab
18 changed files with 334 additions and 309 deletions

1
Cargo.lock generated
View File

@ -167,6 +167,7 @@ name = "common"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror",
"uuid", "uuid",
] ]

View File

@ -16,6 +16,7 @@ use common::CreateMilestone;
use common::DeleteAchievement; use common::DeleteAchievement;
use common::DeleteMilestone; use common::DeleteMilestone;
use common::Milestone; use common::Milestone;
use common::RestResponse;
use common::ToggleAchievement; use common::ToggleAchievement;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@ -37,6 +38,20 @@ struct SharedStateParts {
watcher_tx: tokio::sync::watch::Sender<common::State>, watcher_tx: tokio::sync::watch::Sender<common::State>,
} }
type Response<T> = Result<(StatusCode, Json<RestResponse<T>>), HandlerError>;
// TODO: still needed?
#[derive(Debug, thiserror::Error)]
enum HandlerError {
// #[error("Failed to lock state")]
// LockAppStateError,
}
impl IntoResponse for HandlerError {
fn into_response(self) -> axum::response::Response {
let error_message = format!("{self}");
(StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@ -168,7 +183,7 @@ async fn handle_socket(mut socket: WebSocket, state_watch_rx: tokio::sync::watch
async fn create_achievement( async fn create_achievement(
Extension(app_state): Extension<SharedState>, Extension(app_state): Extension<SharedState>,
Json(create_achievement): Json<CreateAchievement>, Json(create_achievement): Json<CreateAchievement>,
) -> Result<(StatusCode, ()), HandlerError> { ) -> Response<()> {
tracing::debug!("Creating achievement: {create_achievement:?}."); tracing::debug!("Creating achievement: {create_achievement:?}.");
let achievement = Achievement { let achievement = Achievement {
goal: create_achievement.goal, goal: create_achievement.goal,
@ -180,17 +195,21 @@ async fn create_achievement(
lock.watcher_tx lock.watcher_tx
.send(lock.app_state.state.clone()) .send(lock.app_state.state.clone())
.expect("watch channel is closed, every receiver was dropped."); .expect("watch channel is closed, every receiver was dropped.");
Ok((StatusCode::CREATED, ())) Ok((StatusCode::CREATED, Json(Ok(()))))
} }
async fn create_milestone( async fn create_milestone(
Extension(app_state): Extension<SharedState>, Extension(app_state): Extension<SharedState>,
Json(create_milestone): Json<CreateMilestone>, Json(create_milestone): Json<CreateMilestone>,
) -> Result<(StatusCode, ()), HandlerError> { ) -> Response<()> {
tracing::debug!("Creating milestone: {create_milestone:?}."); tracing::debug!("Creating milestone: {create_milestone:?}.");
if create_milestone.goal > 100 { let goal_max = 100;
return Ok((StatusCode::BAD_REQUEST, ())); if create_milestone.goal > goal_max {
return Ok((
StatusCode::BAD_REQUEST,
Json(Err(format!("Max goal allowed: {goal_max}.").into())),
));
} }
let milestone = Milestone { let milestone = Milestone {
@ -202,13 +221,13 @@ async fn create_milestone(
lock.watcher_tx lock.watcher_tx
.send(lock.app_state.state.clone()) .send(lock.app_state.state.clone())
.expect("watch channel is closed, every receiver was dropped."); .expect("watch channel is closed, every receiver was dropped.");
Ok((StatusCode::CREATED, ())) Ok((StatusCode::CREATED, Json(Ok(()))))
} }
async fn delete_milestone( async fn delete_milestone(
Extension(app_state): Extension<SharedState>, Extension(app_state): Extension<SharedState>,
Json(delete_milestone): Json<DeleteMilestone>, Json(delete_milestone): Json<DeleteMilestone>,
) -> Result<(StatusCode, ()), HandlerError> { ) -> Response<()> {
tracing::debug!("Deleting milestone: {delete_milestone:?}."); tracing::debug!("Deleting milestone: {delete_milestone:?}.");
let mut lock = app_state.write().await; let mut lock = app_state.write().await;
if let Some(pos) = lock if let Some(pos) = lock
@ -223,13 +242,13 @@ async fn delete_milestone(
.send(lock.app_state.state.clone()) .send(lock.app_state.state.clone())
.expect("watch channel is closed, every receiver was dropped."); .expect("watch channel is closed, every receiver was dropped.");
} }
Ok((StatusCode::OK, ())) Ok((StatusCode::OK, Json(Ok(()))))
} }
async fn toggle_achievement( async fn toggle_achievement(
Extension(app_state): Extension<SharedState>, Extension(app_state): Extension<SharedState>,
Json(toggle_achievement): Json<ToggleAchievement>, Json(toggle_achievement): Json<ToggleAchievement>,
) -> Result<(StatusCode, ()), HandlerError> { ) -> Response<()> {
tracing::debug!("Toggling achievement: {toggle_achievement:?}."); tracing::debug!("Toggling achievement: {toggle_achievement:?}.");
let mut lock = app_state.write().await; let mut lock = app_state.write().await;
if let Some(achievement) = lock if let Some(achievement) = lock
@ -244,13 +263,13 @@ async fn toggle_achievement(
.send(lock.app_state.state.clone()) .send(lock.app_state.state.clone())
.expect("watch channel is closed, every receiver was dropped."); .expect("watch channel is closed, every receiver was dropped.");
} }
Ok((StatusCode::OK, ())) Ok((StatusCode::OK, Json(Ok(()))))
} }
async fn delete_achievement( async fn delete_achievement(
Extension(app_state): Extension<SharedState>, Extension(app_state): Extension<SharedState>,
Json(delete_achievement): Json<DeleteAchievement>, Json(delete_achievement): Json<DeleteAchievement>,
) -> Result<(StatusCode, ()), HandlerError> { ) -> Response<()> {
tracing::debug!("Deleting achievement: {delete_achievement:?}."); tracing::debug!("Deleting achievement: {delete_achievement:?}.");
let mut lock = app_state.write().await; let mut lock = app_state.write().await;
if let Some(pos) = lock if let Some(pos) = lock
@ -265,7 +284,7 @@ async fn delete_achievement(
.send(lock.app_state.state.clone()) .send(lock.app_state.state.clone())
.expect("watch channel is closed, every receiver was dropped."); .expect("watch channel is closed, every receiver was dropped.");
} }
Ok((StatusCode::OK, ())) Ok((StatusCode::OK, Json(Ok(()))))
} }
async fn shutdown_signal() { async fn shutdown_signal() {
@ -335,17 +354,3 @@ pub enum AppStateWriteError {
#[error("Failed to serialize the state")] #[error("Failed to serialize the state")]
SerializationError(#[from] serde_json::Error), SerializationError(#[from] serde_json::Error),
} }
// TODO: still needed?
#[derive(Debug, thiserror::Error)]
enum HandlerError {
// #[error("Failed to lock state")]
// LockAppStateError,
}
impl IntoResponse for HandlerError {
fn into_response(self) -> axum::response::Response {
let error_message = format!("{self}");
(StatusCode::INTERNAL_SERVER_ERROR, error_message).into_response()
}
}

View File

@ -6,4 +6,5 @@ edition.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror = "1"
uuid.workspace = true uuid.workspace = true

View File

@ -2,6 +2,26 @@ use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
pub type WebSocketMessage = State; pub type WebSocketMessage = State;
pub type RestResponse<T> = Result<T, RestError>;
#[derive(thiserror::Error, Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[error("{error}")]
pub struct RestError {
pub error: String,
}
impl RestError {
pub fn new(err: impl Into<String>) -> Self {
Self { error: err.into() }
}
}
impl<T> From<T> for RestError
where
T: Into<String>,
{
fn from(value: T) -> Self {
RestError::new(value)
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct State { pub struct State {

View File

@ -23,8 +23,10 @@
<link data-trunk rel="rust" href="Cargo.toml" data-bin="app" data-type="main" /> <link data-trunk rel="rust" href="Cargo.toml" data-bin="app" data-type="main" />
<link data-trunk rel="rust" href="Cargo.toml" data-bin="event_bus" data-type="worker" /> <link data-trunk rel="rust" href="Cargo.toml" data-bin="event_bus" data-type="worker" />
</head> </head>
<!--
<body> <body>
<script src="//cdn.jsdelivr.net/npm/eruda"></script> <script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script> <script>eruda.init();</script>
</body> </body>
-->
</html> </html>

View File

@ -2,5 +2,5 @@ use frontend::event_bus::EventBus;
use yew_agent::PublicWorker; use yew_agent::PublicWorker;
fn main() { fn main() {
EventBus::register(); EventBus::<common::WebSocketMessage>::register();
} }

View File

@ -7,7 +7,6 @@ use wasm_bindgen_futures::spawn_local;
use yew::classes; use yew::classes;
use yew::function_component; use yew::function_component;
use yew::html; use yew::html;
use yew::use_memo;
use yew::Callback; use yew::Callback;
use yew::Html; use yew::Html;
use yew::Properties; use yew::Properties;
@ -25,8 +24,6 @@ pub fn AchievementComponent(props: &Props) -> Html {
uuid, uuid,
} = &props.achievement; } = &props.achievement;
let confirm_service = use_memo(|_| ConfirmService, ());
let uuid = *uuid; let uuid = *uuid;
let onclick_toggle = Callback::from(move |_| { let onclick_toggle = Callback::from(move |_| {
@ -40,7 +37,7 @@ pub fn AchievementComponent(props: &Props) -> Html {
}); });
let onclick_delete = Callback::from(move |_| { let onclick_delete = Callback::from(move |_| {
if !confirm_service.confirm("Are you sure you want to delete?") { if !ConfirmService::confirm("Are you sure you want to delete?") {
return; return;
} }
log::info!("Delete achievement confirmed."); log::info!("Delete achievement confirmed.");

View File

@ -1,4 +1,6 @@
use super::error::error_provider::ErrorContext;
use crate::services::rest::RestService; use crate::services::rest::RestService;
use crate::services::rest::RestServiceError;
use common::CreateAchievement; use common::CreateAchievement;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
@ -13,15 +15,16 @@ use yew_router::scope_ext::RouterScopeExt;
#[derive(Debug)] #[derive(Debug)]
pub enum Msg { pub enum Msg {
Submit, Submit,
Reset, SubmitResult(Result<(), RestServiceError>),
UpdateInput(String), UpdateInput(String),
} }
#[derive(Default)] #[derive(Default)]
pub struct CreateAchievementComponent { pub struct CreateAchievementComponent {
input_value: String, input_value: String,
submitted: bool,
input_ref: NodeRef, input_ref: NodeRef,
awaiting_response: bool,
} }
impl Component for CreateAchievementComponent { impl Component for CreateAchievementComponent {
type Message = Msg; type Message = Msg;
@ -42,27 +45,37 @@ impl Component for CreateAchievementComponent {
} }
} }
fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
Msg::Reset => {
self.input_value.clear();
self.submitted = false;
true
}
Msg::Submit => { Msg::Submit => {
log::info!("Creating achievement"); log::info!("Creating achievement");
let payload = CreateAchievement { let payload = CreateAchievement {
goal: self.input_value.clone(), goal: self.input_value.clone(),
}; };
let link = ctx.link().clone();
spawn_local(async move { spawn_local(async move {
match RestService::create_achievement(payload).await { let res = RestService::create_achievement(payload).await;
Ok(_response) => {} link.send_message(Msg::SubmitResult(res));
Err(_err) => {}
}
}); });
self.awaiting_response = true;
self.submitted = true; true
}
Msg::SubmitResult(result) => {
match result {
Ok(_response) => {
let nav = ctx.link().navigator().unwrap();
nav.push(&crate::Route::Root);
}
Err(err) => {
let (error_context, _context_listener) = ctx
.link()
.context(Callback::from(|_: ErrorContext| ()))
.expect("No Error Context Provided");
error_context.dispatch(err.to_string());
}
};
self.awaiting_response = false;
true true
} }
Msg::UpdateInput(value) => { Msg::UpdateInput(value) => {
@ -76,65 +89,39 @@ impl Component for CreateAchievementComponent {
let link = ctx.link().clone(); let link = ctx.link().clone();
let nav = ctx.link().navigator().unwrap(); let nav = ctx.link().navigator().unwrap();
if self.submitted { let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| {
let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| { nav.push(&crate::Route::Root);
nav.push(&crate::Route::Root); });
});
let onclick_add_another = link.callback(|_: web_sys::MouseEvent| Msg::Reset); let onsubmit = link.callback(|e: web_sys::SubmitEvent| {
e.prevent_default();
Msg::Submit
});
html! { let oninput = link.callback(|e: web_sys::InputEvent| {
<> let Some(input) = e
<div class="row" style="margin-top: 15%">
<div class="twelve columns">
<h4>{"Submitted!"}</h4>
</div>
</div>
<div class="row">
<div class="six columns">
<button onclick={onclick_go_back} class="button">{"Go back"}</button>
</div>
<div class="six columns">
<button onclick={onclick_add_another} class="button">{"Add another"}</button>
</div>
</div>
</>
}
} else {
let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| {
nav.push(&crate::Route::Root);
});
let onsubmit = link.callback(|e: web_sys::SubmitEvent| {
e.prevent_default();
Msg::Submit
});
let oninput = link.callback(|e: web_sys::InputEvent| {
let Some(input) = e
.target() .target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) else { unreachable!() }; .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) else { unreachable!() };
Msg::UpdateInput(input.value()) Msg::UpdateInput(input.value())
}); });
html! { html! {
<> <>
<div class="row"> <div class="row">
<button onclick={onclick_go_back} class="button">{"Back"}</button> <button onclick={onclick_go_back} class="button">{"Back"}</button>
</div>
<form {onsubmit} >
<hr />
<div class="row">
<div class="twelve columns">
<label for="achievementInput">{"New achievement"}</label>
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.clone()} class="u-full-width" type="text" id="achievementInput" />
</div> </div>
</div>
<form {onsubmit} > <input class="button-primary" type="submit" value="Submit" disabled={self.awaiting_response} />
<hr /> </form>
<div class="row"> </>
<div class="twelve columns">
<label for="achievementInput">{"New achievement"}</label>
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.clone()} class="u-full-width" type="text" id="achievementInput" />
</div>
</div>
<input class="button-primary" type="submit" value="Submit" />
</form>
</>
}
} }
} }
} }

View File

@ -22,8 +22,10 @@ pub enum Msg {
#[derive(Default)] #[derive(Default)]
pub struct CreateMilestoneComponent { pub struct CreateMilestoneComponent {
input_value: String, input_value: String,
submitted: bool,
input_ref: NodeRef, input_ref: NodeRef,
/// Submitted and awaiting response
awaiting_response: bool,
} }
impl Component for CreateMilestoneComponent { impl Component for CreateMilestoneComponent {
type Message = Msg; type Message = Msg;
@ -57,7 +59,7 @@ impl Component for CreateMilestoneComponent {
let res = RestService::create_milestone(payload).await; let res = RestService::create_milestone(payload).await;
link.send_message(Msg::SubmitResult(res)); link.send_message(Msg::SubmitResult(res));
}); });
self.submitted = true; self.awaiting_response = true;
true true
} }
@ -72,10 +74,10 @@ impl Component for CreateMilestoneComponent {
.link() .link()
.context(Callback::from(|_: ErrorContext| ())) .context(Callback::from(|_: ErrorContext| ()))
.expect("No Error Context Provided"); .expect("No Error Context Provided");
log::info!("dispatching error");
error_context.dispatch(err.to_string()); error_context.dispatch(err.to_string());
} }
}; };
self.awaiting_response = false;
true true
} }
Msg::UpdateInput(value) => { Msg::UpdateInput(value) => {
@ -89,60 +91,39 @@ impl Component for CreateMilestoneComponent {
let link = ctx.link().clone(); let link = ctx.link().clone();
let nav = ctx.link().navigator().unwrap(); let nav = ctx.link().navigator().unwrap();
if self.submitted { let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| {
let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| { nav.push(&crate::Route::Root);
nav.push(&crate::Route::Root); });
});
html! { let onsubmit = link.callback(|e: web_sys::SubmitEvent| {
<> e.prevent_default();
<div class="row" style="margin-top: 15%"> Msg::Submit
<div class="twelve columns"> });
<h4>{"Submitted!"}</h4>
</div>
</div>
<div class="row">
<div class="six columns">
<button onclick={onclick_go_back} class="button">{"Go back"}</button>
</div>
</div>
</>
}
} else {
let onclick_go_back = Callback::from(move |_: web_sys::MouseEvent| {
nav.push(&crate::Route::Root);
});
let onsubmit = link.callback(|e: web_sys::SubmitEvent| { let oninput = link.callback(|e: web_sys::InputEvent| {
e.prevent_default(); let Some(input) = e
Msg::Submit
});
let oninput = link.callback(|e: web_sys::InputEvent| {
let Some(input) = e
.target() .target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) else { unreachable!() }; .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) else { unreachable!() };
Msg::UpdateInput(input.value()) Msg::UpdateInput(input.value())
}); });
html! { html! {
<> <>
<div class="row"> <div class="row">
<button onclick={onclick_go_back} class="button">{"Back"}</button> <button onclick={onclick_go_back} class="button">{"Back"}</button>
</div>
<form {onsubmit} >
<hr />
<div class="row">
<div class="twelve columns">
<label for="milestoneInput">{"New milestone"}</label>
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.to_string()} class="u-full-width" type="number" id="milestoneInput" />
</div> </div>
</div>
<form {onsubmit} > <input class="button-primary" type="submit" value="Submit" disabled={self.awaiting_response} />
<hr /> </form>
<div class="row"> </>
<div class="twelve columns">
<label for="milestoneInput">{"New milestone"}</label>
<input ref={self.input_ref.clone()} {oninput} value={self.input_value.to_string()} class="u-full-width" type="number" id="milestoneInput" />
</div>
</div>
<input class="button-primary" type="submit" value="Submit" />
</form>
</>
}
} }
} }
} }

View File

@ -54,12 +54,16 @@ impl ErrorComponent {
match &self.error { match &self.error {
Some(error_msg) => { Some(error_msg) => {
html! { html! {
<div class="error-message"> <>
{ error_msg } <div class="row">
<button onclick={onclick_hide}> <h5><i style="margin-right: 1rem" class="fas fa-triangle-exclamation" />{"Error"}</h5>
<p>{ error_msg }</p>
<button onclick={onclick_hide} class="button-primary">
{ "Hide" } { "Hide" }
</button> </button>
</div> </div>
<hr />
</>
} }
} }
None => html! {}, None => html! {},

View File

@ -13,8 +13,6 @@ pub struct Props {
#[function_component(MilestoneComponent)] #[function_component(MilestoneComponent)]
pub fn milestone_component(props: &Props) -> Html { pub fn milestone_component(props: &Props) -> Html {
let confirm_service = use_memo(|_| ConfirmService, ());
let unfilled = props.milestone.goal - props.completed_achievements.min(props.milestone.goal); let unfilled = props.milestone.goal - props.completed_achievements.min(props.milestone.goal);
let filled = props.completed_achievements.min(props.milestone.goal); let filled = props.completed_achievements.min(props.milestone.goal);
@ -32,7 +30,7 @@ pub fn milestone_component(props: &Props) -> Html {
let uuid = props.milestone.uuid; let uuid = props.milestone.uuid;
let onclick_delete = Callback::from(move |_| { let onclick_delete = Callback::from(move |_| {
if !confirm_service.confirm("Are you sure you want to delete?") { if !ConfirmService::confirm("Are you sure you want to delete?") {
return; return;
} }
log::info!("Delete achievement confirmed."); log::info!("Delete achievement confirmed.");
@ -48,7 +46,29 @@ pub fn milestone_component(props: &Props) -> Html {
html! { html! {
<div class="row flex"> <div class="row flex">
<p style="text-align: center" class="flex-grow"> <p style="text-align: center" class="flex-grow">
{format!("{} / {}", props.completed_achievements, props.milestone.goal)} {
if unfilled == 0 {
// html! { <i style="color: var(--secondary-color)" class="fas fa-gift" /> }
html! {
<>
{"🎉"}
<span style="display: inline-block; width: 0.5em"></span>
</>
}
} else { html! {} }
}
{ format!("{} / {}", filled, filled + unfilled) }
{
if unfilled == 0 {
// html! { <i style="color: var(--secondary-color)" class="fas fa-gift" /> }
html! {
<>
<span style="display: inline-block; width: 0.5em"></span>
{"🎉"}
</>
}
} else { html! {} }
}
<br /> <br />
{filled_stars} {filled_stars}
{unfilled_stars} {unfilled_stars}

View File

@ -1,121 +1,77 @@
use crate::components::achievement::AchievementComponent; use crate::components::achievement::AchievementComponent;
use crate::components::milestone::MilestoneComponent; use crate::components::milestone::MilestoneComponent;
use crate::event_bus::EventBus; use yew::functional::*;
use crate::services::websocket::WebsocketService;
use std::rc::Rc;
use yew::prelude::*; use yew::prelude::*;
use yew_agent::Bridge; use yew_router::prelude::*;
use yew_agent::Bridged;
use yew_router::scope_ext::RouterScopeExt;
pub struct Root { #[function_component]
_wss: WebsocketService, pub fn Root() -> Html {
_producer: Box<dyn Bridge<EventBus>>, let nav = use_navigator().expect("cannot get navigator");
let app_state = use_context::<crate::AppState>().expect("no app state ctx found");
/// None until the first websocket message is received. let onclick_create_achievement = {
state: Option<common::State>, let nav = nav.clone();
} Callback::from(move |_: web_sys::MouseEvent| {
nav.push(&crate::Route::CreateAchievement);
})
};
pub enum Msg { let onclick_create_milestone = Callback::from(move |_: web_sys::MouseEvent| {
HandleMsg(String), nav.push(&crate::Route::CreateMilestone);
} });
impl Component for Root { let achievements = app_state
type Message = Msg; .state
type Properties = (); .achievements
.iter()
fn create(ctx: &Context<Self>) -> Self { .cloned()
let wss = WebsocketService::new(); .map(|a| {
html! {
let cb = { <AchievementComponent achievement={a} />
let link = ctx.link().clone();
move |e| link.send_message(Msg::HandleMsg(e))
};
Self {
_wss: wss,
_producer: EventBus::bridge(Rc::new(cb)),
state: None,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::HandleMsg(s) => {
let msg: common::WebSocketMessage = serde_json::from_str(&s).unwrap();
self.state = Some(msg);
true
} }
} })
} .collect::<Html>();
fn view(&self, ctx: &Context<Self>) -> Html { let completed_achievements = app_state
let nav = ctx.link().navigator().unwrap(); .state
.achievements
.iter()
.filter(|a| a.completed)
.count();
let onclick_create_achievement = { let mut milestones = app_state.state.milestones.clone();
let nav = nav.clone(); milestones.sort_by_key(|m| m.goal);
Callback::from(move |_: web_sys::MouseEvent| { let milestones = milestones.into_iter().map(|m| html! { <MilestoneComponent milestone={m} completed_achievements={completed_achievements} /> }).collect::<Html>();
nav.push(&crate::Route::CreateAchievement);
})
};
let onclick_create_milestone = Callback::from(move |_: web_sys::MouseEvent| { html! {
nav.push(&crate::Route::CreateMilestone); <>
});
let Some(state) = &self.state else { <h1>{"Polter A"}<span style="color: var(--danger-color); text-decoration: line-through">{"bend"}</span>{"chievements!"}</h1>
return html! {
<p>{"loading..."}</p>
};
};
let achievements = state <hr />
.achievements <div class="row flex flex-wrap">
.iter() <div class="flex-grow">
.cloned() <h3>{"Milestones"}</h3>
.map(|a| { </div>
html! {
<AchievementComponent achievement={a} />
}
})
.collect::<Html>();
let completed_achievements = state.achievements.iter().filter(|a| a.completed).count(); <div class="flex-intrinsic-size">
<button onclick={onclick_create_milestone} class="button-primary">{"New"}</button>
</div>
</div>
{milestones}
let mut milestones = state.milestones.clone(); <hr />
milestones.sort_by_key(|m| m.goal); <div class="row flex flex-wrap">
let milestones = milestones.into_iter().map(|m| html! { <MilestoneComponent milestone={m} completed_achievements={completed_achievements} /> }).collect::<Html>(); <div class="flex-grow">
<h3>{"Achievements"}</h3>
</div>
html! { <div class="flex-intrinsic-size">
<> <button onclick={onclick_create_achievement} class="button-primary">{"New"}</button>
</div>
</div>
{achievements}
<h1>{"Polter A"}<span style="color: var(--danger-color); text-decoration: line-through">{"bend"}</span>{"chievements!"}</h1> </>
<hr />
<div class="row flex flex-wrap">
<div class="flex-grow">
<h3>{"Milestones"}</h3>
</div>
<div class="flex-intrinsic-size">
<button onclick={onclick_create_milestone} class="button-primary">{"New"}</button>
</div>
</div>
{milestones}
<hr />
<div class="row flex flex-wrap">
<div class="flex-grow">
<h3>{"Achievements"}</h3>
</div>
<div class="flex-intrinsic-size">
<button onclick={onclick_create_achievement} class="button-primary">{"New"}</button>
</div>
</div>
{achievements}
</>
}
} }
} }

View File

@ -1,3 +1,4 @@
use serde::de::DeserializeOwned;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use std::collections::HashSet; use std::collections::HashSet;
@ -5,23 +6,30 @@ use yew_agent::HandlerId;
use yew_agent::Public; use yew_agent::Public;
use yew_agent::WorkerLink; use yew_agent::WorkerLink;
pub struct EventBus { pub struct EventBus<T>
where
T: 'static + Serialize + DeserializeOwned + Clone,
{
link: WorkerLink<Self>, link: WorkerLink<Self>,
subscribers: HashSet<HandlerId>, subscribers: HashSet<HandlerId>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub enum EventBusInput { pub enum EventBusInput<T> {
EventBusMsg(String), EventBusMsg(T),
} }
impl yew_agent::Worker for EventBus { impl<T> yew_agent::Worker for EventBus<T>
where
T: 'static + Serialize + DeserializeOwned + Clone,
{
type Reach = Public<Self>; type Reach = Public<Self>;
type Input = EventBusInput; type Input = EventBusInput<T>;
type Output = String; type Output = T;
type Message = (); type Message = ();
fn create(link: WorkerLink<Self>) -> Self { fn create(link: WorkerLink<Self>) -> Self {
log::debug!("Creating Event Bus.");
Self { Self {
link, link,
subscribers: HashSet::new(), subscribers: HashSet::new(),
@ -32,9 +40,13 @@ impl yew_agent::Worker for EventBus {
fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) { fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) {
match msg { match msg {
EventBusInput::EventBusMsg(s) => { EventBusInput::EventBusMsg(msg) => {
log::debug!(
"Event bus received message. Responding to {} subscribers.",
self.subscribers.len()
);
for sub in &self.subscribers { for sub in &self.subscribers {
self.link.respond(*sub, s.clone()); self.link.respond(*sub, msg.clone());
} }
} }
} }

View File

@ -1,11 +1,14 @@
use crate::components::error::error_component::ErrorComponent; use crate::components::error::error_component::ErrorComponent;
use crate::components::error::error_provider::ErrorProvider; use crate::components::error::error_provider::ErrorProvider;
use crate::event_bus::EventBus;
use crate::services::websocket::WebsocketService;
use components::admin::Admin; use components::admin::Admin;
use components::create_achievement::CreateAchievementComponent; use components::create_achievement::CreateAchievementComponent;
use components::create_milestone::CreateMilestoneComponent; use components::create_milestone::CreateMilestoneComponent;
use components::root::Root; use components::root::Root;
use std::rc::Rc; use std::rc::Rc;
use yew::prelude::*; use yew::prelude::*;
use yew_agent::Bridged;
use yew_router::BrowserRouter; use yew_router::BrowserRouter;
use yew_router::Routable; use yew_router::Routable;
use yew_router::Switch; use yew_router::Switch;
@ -41,16 +44,39 @@ fn switch(selected_route: Route) -> Html {
#[derive(Default, Clone, Debug, PartialEq, Eq)] #[derive(Default, Clone, Debug, PartialEq, Eq)]
struct AppStateInner { struct AppStateInner {
/// The part of the state that comes from the server
state: common::State, state: common::State,
} }
impl Reducible for AppStateInner {
type Action = common::WebSocketMessage;
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
Rc::new(Self { state: action })
}
}
type AppState = Rc<AppStateInner>; type AppState = Rc<AppStateInner>;
#[function_component] #[function_component]
pub fn App() -> Html { pub fn App() -> Html {
let ctx = use_state(|| Rc::new(AppStateInner::default())); let app_state = use_reducer(AppStateInner::default);
let app_state_dispatcher = app_state.dispatcher();
let _event_bus = use_memo(
|_| {
log::info!("Creating event bus bridge.");
EventBus::<common::WebSocketMessage>::bridge(Rc::new(move |ws_msg: common::WebSocketMessage| {
log::debug!("dispatching websocket msg to reducer");
app_state_dispatcher.dispatch(ws_msg);
}))
},
(),
);
let _wss = use_memo(|_| Rc::new(WebsocketService::connect()), ());
let rc_app_state = Rc::new((*app_state).clone());
html! { html! {
<ContextProvider<AppState> context={(*ctx).clone()}> <ContextProvider<AppState> context={rc_app_state}>
<ErrorProvider> <ErrorProvider>
<div class="container" style="margin-top: 5%; margin-bottom: 25%"> <div class="container" style="margin-top: 5%; margin-bottom: 25%">
<ErrorComponent /> <ErrorComponent />

View File

@ -2,7 +2,7 @@
pub struct ConfirmService; pub struct ConfirmService;
impl ConfirmService { impl ConfirmService {
pub fn confirm(&self, message: &str) -> bool { pub fn confirm(message: &str) -> bool {
web_sys::window() web_sys::window()
.expect("no global `window` exists") .expect("no global `window` exists")
.confirm_with_message(message) .confirm_with_message(message)

View File

@ -2,8 +2,10 @@ use common::CreateAchievement;
use common::CreateMilestone; use common::CreateMilestone;
use common::DeleteAchievement; use common::DeleteAchievement;
use common::DeleteMilestone; use common::DeleteMilestone;
use common::RestResponse;
use common::ToggleAchievement; use common::ToggleAchievement;
use reqwasm::http::Request; use reqwasm::http::Request;
use serde::de::DeserializeOwned;
use serde::Serialize; use serde::Serialize;
pub struct RestService; pub struct RestService;
@ -13,11 +15,14 @@ pub enum RestServiceError {
#[error("Failed to serialize/deserialize data: {0}")] #[error("Failed to serialize/deserialize data: {0}")]
SerdeError(#[from] serde_json::Error), SerdeError(#[from] serde_json::Error),
#[error(transparent)]
RestError(#[from] common::RestError),
#[error("Failed request: {0}")]
ReqwasmError(#[from] reqwasm::Error),
#[error("Unexpected response")] #[error("Unexpected response")]
UnexpectedResponse(reqwasm::http::Response), UnexpectedResponse(reqwasm::http::Response),
#[error("Failed request")]
ReqwasmError(#[from] reqwasm::Error),
} }
impl RestService { impl RestService {
@ -29,31 +34,26 @@ impl RestService {
format!("http://{}:4000{endpoint}", Self::hostname()) format!("http://{}:4000{endpoint}", Self::hostname())
} }
async fn post_json<T: Serialize>(payload: T, path: &str) -> Result<(), RestServiceError> { async fn post_json<P: Serialize, T: DeserializeOwned>(
payload: P,
path: &str,
) -> Result<T, RestServiceError> {
let req = Request::post(&Self::url_to(path)) let req = Request::post(&Self::url_to(path))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(serde_json::to_string(&payload)?); .body(serde_json::to_string(&payload)?);
let res = req.send().await; let response = req.send().await.map_err(|err| {
match res { log::error!("Request failed: {:?}", err);
Ok(response) => { err
let status_code = response.status(); })?;
if (200..300).contains(&status_code) {
Ok(()) if let Ok(rest_response) = response.json::<RestResponse<T>>().await {
} else { return rest_response.map_err(Into::into);
log::warn!(
"Received unexpected status code: {}. Response: {:?}",
status_code,
response
);
Err(RestServiceError::UnexpectedResponse(response))
}
}
Err(err) => {
log::error!("Request failed: {:?}", err);
Err(err.into())
}
} }
let err = RestServiceError::UnexpectedResponse(response);
log::warn!("{err:?}");
Err(err)
} }
pub async fn toggle_achievement(payload: ToggleAchievement) -> Result<(), RestServiceError> { pub async fn toggle_achievement(payload: ToggleAchievement) -> Result<(), RestServiceError> {

View File

@ -1,49 +1,57 @@
use crate::event_bus::EventBus; use crate::event_bus::EventBus;
use crate::event_bus::EventBusInput; use crate::event_bus::EventBusInput;
use futures::channel::mpsc::Sender; use futures::channel::mpsc::Sender;
use futures::SinkExt;
use futures::StreamExt; use futures::StreamExt;
use reqwasm::websocket::futures::WebSocket; use reqwasm::websocket::futures::WebSocket;
use reqwasm::websocket::Message; use reqwasm::websocket::Message;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use yew_agent::Dispatched; use yew_agent::Dispatched;
#[derive(Debug)]
pub struct WebsocketService { pub struct WebsocketService {
pub tx: Sender<String>, // No messages sent from app to server on websocket at the moment.
pub tx: Sender<()>,
} }
impl WebsocketService { impl WebsocketService {
pub fn new() -> Self { pub fn connect() -> Self {
let hostname = web_sys::window().unwrap().location().hostname().unwrap(); let hostname = web_sys::window().unwrap().location().hostname().unwrap();
let ws_addr = format!("ws://{hostname}:4000/ws"); let ws_addr = format!("ws://{hostname}:4000/ws");
let ws = WebSocket::open(&ws_addr).unwrap(); let ws = WebSocket::open(&ws_addr).unwrap();
log::info!("Opened websocket connection to {ws_addr}");
let (mut write, mut read) = ws.split(); let (_write, mut read) = ws.split();
let (in_tx, mut in_rx) = futures::channel::mpsc::channel::<String>(1000); let (in_tx, mut in_rx) = futures::channel::mpsc::channel::<()>(1000);
let mut event_bus = EventBus::dispatcher(); let mut event_bus = EventBus::<common::WebSocketMessage>::dispatcher();
// App to Server
spawn_local(async move { spawn_local(async move {
while let Some(s) = in_rx.next().await { while let Some(()) = in_rx.next().await {
log::debug!("got event from channel! {}", s); // write.send(Message::Text(s)).await.unwrap();
write.send(Message::Text(s)).await.unwrap();
} }
}); });
// Server to App
spawn_local(async move { spawn_local(async move {
while let Some(msg) = read.next().await { while let Some(msg) = read.next().await {
match msg { match msg {
Ok(Message::Text(data)) => { Ok(Message::Text(data)) => {
event_bus.send(EventBusInput::EventBusMsg(data)); match serde_json::from_str::<common::WebSocketMessage>(&data) {
} Ok(ws_msg) => {
Ok(Message::Bytes(b)) => { log::debug!("Received ws message. Dispatching to event bus.");
let decoded = std::str::from_utf8(&b); event_bus.send(EventBusInput::EventBusMsg(ws_msg));
if let Ok(val) = decoded { }
event_bus.send(EventBusInput::EventBusMsg(val.into())); Err(err) => {
log::error!("{err:?}");
}
} }
} }
Err(e) => { Ok(Message::Bytes(_)) => {
log::error!("ws: {:?}", e); log::warn!("Received binary data on websocket. This is unhandled");
}
Err(err) => {
log::error!("ws error: {err:?}");
} }
} }
} }

View File

@ -73,3 +73,8 @@
.flex-grow { .flex-grow {
flex-grow: 1; flex-grow: 1;
} }
input:disabled {
filter: brightness(75%);
cursor: not-allowed;
}