Repository: lldap/lldap (language: rust)

Issue:
QoL - Show avatar in navigation if set
If the logged-in user has an avatar it would be nice if it could show up on the web interface across all pages. Makes the experience slightly more personalized! :) 


Discussion / hints:
Similar to #492 

Fix patch:
diff --git a/app/src/components/app.rs b/app/src/components/app.rs
index b9856c02..b0a11c5b 100644
--- a/app/src/components/app.rs
+++ b/app/src/components/app.rs
@@ -1,5 +1,6 @@
 use crate::{
     components::{
+        banner::Banner,
         change_password::ChangePasswordForm,
         create_group::CreateGroupForm,
         create_group_attribute::CreateGroupAttributeForm,
@@ -9,7 +10,6 @@ use crate::{
         group_schema_table::ListGroupSchema,
         group_table::GroupTable,
         login::LoginForm,
-        logout::LogoutButton,
         reset_password_step1::ResetPasswordStep1Form,
         reset_password_step2::ResetPasswordStep2Form,
         router::{AppRoute, Link, Redirect},
@@ -21,7 +21,6 @@ use crate::{
 };
 
 use gloo_console::error;
-use wasm_bindgen::prelude::*;
 use yew::{
     function_component,
     html::Scope,
@@ -34,25 +33,6 @@ use yew_router::{
     BrowserRouter, Switch,
 };
 
-#[wasm_bindgen]
-extern "C" {
-    #[wasm_bindgen(js_namespace = darkmode)]
-    fn toggleDarkMode(doSave: bool);
-
-    #[wasm_bindgen]
-    fn inDarkMode() -> bool;
-}
-
-#[function_component(DarkModeToggle)]
-pub fn dark_mode_toggle() -> Html {
-    html! {
-      <div class="form-check form-switch">
-        <input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
-        <label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
-      </div>
-    }
-}
-
 #[function_component(AppContainer)]
 pub fn app_container() -> Html {
     html! {
@@ -139,10 +119,11 @@ impl Component for App {
     fn view(&self, ctx: &Context<Self>) -> Html {
         let link = ctx.link().clone();
         let is_admin = self.is_admin();
+        let username = self.user_info.clone().map(|(username, _)| username);
         let password_reset_enabled = self.password_reset_enabled;
         html! {
           <div>
-            {self.view_banner(ctx)}
+            <Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
             <div class="container py-3 bg-kug">
               <div class="row justify-content-center" style="padding-bottom: 80px;">
                 <main class="py-3" style="max-width: 1000px">
@@ -279,107 +260,6 @@ impl App {
         }
     }
 
-    fn view_banner(&self, ctx: &Context<Self>) -> Html {
-        html! {
-          <header class="p-2 mb-3 border-bottom">
-            <div class="container">
-              <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
-                <a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
-                  <h2>{"LLDAP"}</h2>
-                </a>
-
-                <ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
-                  {if self.is_admin() { html! {
-                    <>
-                      <li>
-                        <Link
-                          classes="nav-link px-2 h6"
-                          to={AppRoute::ListUsers}>
-                          <i class="bi-people me-2"></i>
-                          {"Users"}
-                        </Link>
-                      </li>
-                      <li>
-                        <Link
-                          classes="nav-link px-2 h6"
-                          to={AppRoute::ListGroups}>
-                          <i class="bi-collection me-2"></i>
-                          {"Groups"}
-                        </Link>
-                      </li>
-                      <li>
-                        <Link
-                          classes="nav-link px-2 h6"
-                          to={AppRoute::ListUserSchema}>
-                          <i class="bi-list-ul me-2"></i>
-                          {"User schema"}
-                        </Link>
-                      </li>
-                      <li>
-                        <Link
-                          classes="nav-link px-2 h6"
-                          to={AppRoute::ListGroupSchema}>
-                          <i class="bi-list-ul me-2"></i>
-                          {"Group schema"}
-                        </Link>
-                      </li>
-                    </>
-                  } } else { html!{} } }
-                </ul>
-                { self.view_user_menu(ctx) }
-                <DarkModeToggle />
-              </div>
-            </div>
-          </header>
-        }
-    }
-
-    fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
-        if let Some((user_id, _)) = &self.user_info {
-            let link = ctx.link();
-            html! {
-              <div class="dropdown text-end">
-                <a href="#"
-                  class="d-block nav-link text-decoration-none dropdown-toggle"
-                  id="dropdownUser"
-                  data-bs-toggle="dropdown"
-                  aria-expanded="false">
-                  <svg xmlns="http://www.w3.org/2000/svg"
-                    width="32"
-                    height="32"
-                    fill="currentColor"
-                    class="bi bi-person-circle"
-                    viewBox="0 0 16 16">
-                    <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
-                    <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
-                  </svg>
-                  <span class="ms-2">
-                    {user_id}
-                  </span>
-                </a>
-                <ul
-                  class="dropdown-menu text-small dropdown-menu-lg-end"
-                  aria-labelledby="dropdownUser1"
-                  style="">
-                  <li>
-                    <Link
-                      classes="dropdown-item"
-                      to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
-                      {"View details"}
-                    </Link>
-                  </li>
-                  <li><hr class="dropdown-divider" /></li>
-                  <li>
-                    <LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
-                  </li>
-                </ul>
-              </div>
-            }
-        } else {
-            html! {}
-        }
-    }
-
     fn view_footer(&self) -> Html {
         html! {
           <footer class="text-center fixed-bottom text-muted bg-light py-2">
diff --git a/app/src/components/avatar.rs b/app/src/components/avatar.rs
new file mode 100644
index 00000000..beb5ac9b
--- /dev/null
+++ b/app/src/components/avatar.rs
@@ -0,0 +1,87 @@
+use crate::infra::functional::{use_graphql_call, LoadableResult};
+use graphql_client::GraphQLQuery;
+use yew::{function_component, html, virtual_dom::AttrValue, Properties};
+
+#[derive(GraphQLQuery)]
+#[graphql(
+    schema_path = "../schema.graphql",
+    query_path = "queries/get_user_details.graphql",
+    response_derives = "Debug, Hash, PartialEq, Eq, Clone",
+    custom_scalars_module = "crate::infra::graphql"
+)]
+pub struct GetUserDetails;
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+    pub user: AttrValue,
+    #[prop_or(32)]
+    pub width: i32,
+    #[prop_or(32)]
+    pub height: i32,
+}
+
+#[function_component(Avatar)]
+pub fn avatar(props: &Props) -> Html {
+    let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
+        id: props.user.to_string(),
+    });
+
+    match &(*user_details) {
+        LoadableResult::Loaded(Ok(response)) => {
+            let avatar = response.user.avatar.clone();
+            match &avatar {
+                Some(data) => html! {
+                  <img
+                    id="avatarDisplay"
+                    src={format!("data:image/jpeg;base64, {}", data)}
+                    style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
+                    alt="Avatar" />
+                },
+                None => html! {
+                  <BlankAvatarDisplay
+                    width={props.width}
+                    height={props.height} />
+                },
+            }
+        }
+        LoadableResult::Loaded(Err(error)) => html! {
+          <BlankAvatarDisplay
+            error={error.to_string()}
+            width={props.width}
+            height={props.height} />
+        },
+        LoadableResult::Loading => html! {
+          <BlankAvatarDisplay
+            width={props.width}
+            height={props.height} />
+        },
+    }
+}
+
+#[derive(Properties, PartialEq)]
+struct BlankAvatarDisplayProps {
+    #[prop_or(None)]
+    pub error: Option<AttrValue>,
+    pub width: i32,
+    pub height: i32,
+}
+
+#[function_component(BlankAvatarDisplay)]
+fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
+    let fill = match &props.error {
+        Some(_) => "red",
+        None => "currentColor",
+    };
+    html! {
+      <svg xmlns="http://www.w3.org/2000/svg"
+        width={props.width.to_string()}
+        height={props.height.to_string()}
+        fill={fill}
+        class="bi bi-person-circle"
+        viewBox="0 0 16 16">
+        <title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
+        <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
+        <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
+      </svg>
+    }
+}
diff --git a/app/src/components/banner.rs b/app/src/components/banner.rs
new file mode 100644
index 00000000..41caf64a
--- /dev/null
+++ b/app/src/components/banner.rs
@@ -0,0 +1,132 @@
+use crate::components::{
+    avatar::Avatar,
+    logout::LogoutButton,
+    router::{AppRoute, Link},
+};
+use wasm_bindgen::prelude::wasm_bindgen;
+use yew::{function_component, html, Callback, Properties};
+
+#[derive(Properties, PartialEq)]
+pub struct Props {
+    pub is_admin: bool,
+    pub username: Option<String>,
+    pub on_logged_out: Callback<()>,
+}
+
+#[function_component(Banner)]
+pub fn banner(props: &Props) -> Html {
+    html! {
+      <header class="p-2 mb-3 border-bottom">
+        <div class="container">
+          <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
+            <a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
+              <h2>{"LLDAP"}</h2>
+            </a>
+
+            <ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
+              {if props.is_admin { html! {
+                <>
+                  <li>
+                    <Link
+                      classes="nav-link px-2 h6"
+                      to={AppRoute::ListUsers}>
+                      <i class="bi-people me-2"></i>
+                      {"Users"}
+                    </Link>
+                  </li>
+                  <li>
+                    <Link
+                      classes="nav-link px-2 h6"
+                      to={AppRoute::ListGroups}>
+                      <i class="bi-collection me-2"></i>
+                      {"Groups"}
+                    </Link>
+                  </li>
+                  <li>
+                    <Link
+                      classes="nav-link px-2 h6"
+                      to={AppRoute::ListUserSchema}>
+                      <i class="bi-list-ul me-2"></i>
+                      {"User schema"}
+                    </Link>
+                  </li>
+                  <li>
+                    <Link
+                      classes="nav-link px-2 h6"
+                      to={AppRoute::ListGroupSchema}>
+                      <i class="bi-list-ul me-2"></i>
+                      {"Group schema"}
+                    </Link>
+                  </li>
+                </>
+              } } else { html!{} } }
+            </ul>
+            <UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
+            <DarkModeToggle />
+          </div>
+        </div>
+      </header>
+    }
+}
+
+#[derive(Properties, PartialEq)]
+struct UserMenuProps {
+    pub username: Option<String>,
+    pub on_logged_out: Callback<()>,
+}
+
+#[function_component(UserMenu)]
+fn user_menu(props: &UserMenuProps) -> Html {
+    match &props.username {
+        Some(username) => html! {
+          <div class="dropdown text-end">
+            <a href="#"
+              class="d-block nav-link text-decoration-none dropdown-toggle"
+              id="dropdownUser"
+              data-bs-toggle="dropdown"
+              aria-expanded="false">
+              <Avatar user={username.clone()} />
+              <span class="ms-2">
+                {username}
+              </span>
+            </a>
+            <ul
+              class="dropdown-menu text-small dropdown-menu-lg-end"
+              aria-labelledby="dropdownUser1"
+              style="">
+              <li>
+                <Link
+                  classes="dropdown-item"
+                  to={AppRoute::UserDetails{ user_id: username.to_string() }}>
+                  {"View details"}
+                </Link>
+              </li>
+              <li><hr class="dropdown-divider" /></li>
+              <li>
+                <LogoutButton on_logged_out={props.on_logged_out.clone()} />
+              </li>
+            </ul>
+          </div>
+        },
+        _ => html! {},
+    }
+}
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(js_namespace = darkmode)]
+    fn toggleDarkMode(doSave: bool);
+
+    #[wasm_bindgen]
+    fn inDarkMode() -> bool;
+}
+
+#[function_component(DarkModeToggle)]
+fn dark_mode_toggle() -> Html {
+    html! {
+      <div class="form-check form-switch">
+        <input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
+        <label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
+      </div>
+    }
+}
diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs
index 5229f443..39897ed2 100644
--- a/app/src/components/mod.rs
+++ b/app/src/components/mod.rs
@@ -1,6 +1,8 @@
 pub mod add_group_member;
 pub mod add_user_to_group;
 pub mod app;
+pub mod avatar;
+pub mod banner;
 pub mod change_password;
 pub mod create_group;
 pub mod create_group_attribute;
diff --git a/app/src/infra/functional.rs b/app/src/infra/functional.rs
new file mode 100644
index 00000000..2e2cbb5b
--- /dev/null
+++ b/app/src/infra/functional.rs
@@ -0,0 +1,38 @@
+use crate::infra::api::HostService;
+use anyhow::Result;
+use graphql_client::GraphQLQuery;
+use wasm_bindgen_futures::spawn_local;
+use yew::{use_effect, use_state, UseStateHandle};
+
+// Enum to represent a result that is fetched asynchronously.
+#[derive(Debug)]
+pub enum LoadableResult<T> {
+    // The result is still being fetched
+    Loading,
+    // The async call is completed
+    Loaded(Result<T>),
+}
+
+pub fn use_graphql_call<QueryType>(
+    variables: QueryType::Variables,
+) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
+where
+    QueryType: GraphQLQuery + 'static,
+{
+    let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
+        use_state(|| LoadableResult::Loading);
+    {
+        let loadable_result = loadable_result.clone();
+        use_effect(move || {
+            let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
+
+            spawn_local(async move {
+                let response = task.await;
+                loadable_result.set(LoadableResult::Loaded(response));
+            });
+
+            || ()
+        })
+    }
+    loadable_result.clone()
+}
diff --git a/app/src/infra/mod.rs b/app/src/infra/mod.rs
index 663ee083..4c46411f 100644
--- a/app/src/infra/mod.rs
+++ b/app/src/infra/mod.rs
@@ -1,6 +1,7 @@
 pub mod api;
 pub mod common_component;
 pub mod cookies;
+pub mod functional;
 pub mod graphql;
 pub mod modal;
 pub mod schema;


Test patch: