diff --git a/client/src/api/types/company.rs b/client/src/api/types/company.rs index 19a5eb7..3aaa9b6 100644 --- a/client/src/api/types/company.rs +++ b/client/src/api/types/company.rs @@ -1,8 +1,16 @@ use serde::{Deserialize, Serialize}; +use crate::components::base_async_select::IntoAsyncSelectListItem; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Company { pub id: i32, pub name: String, pub slug: String, } + +impl IntoAsyncSelectListItem for Company { + fn to_select_list_item(&self) -> String { + format!("{}", self.name) + } +} diff --git a/client/src/components/base_async_select.rs b/client/src/components/base_async_select.rs new file mode 100644 index 0000000..68fb518 --- /dev/null +++ b/client/src/components/base_async_select.rs @@ -0,0 +1,108 @@ +use serde::Deserialize; +use sycamore::prelude::*; + +pub trait IntoAsyncSelectListItem { + fn to_select_list_item(&self) -> String; +} + +#[derive(Clone)] +pub struct AsyncSelectRx +where + T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, +{ + pub selected_item: Signal>, +} + +#[component(BaseAsyncSelect)] +pub fn create_component(AsyncSelectRx { selected_item }: AsyncSelectRx) -> View +where + T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, + for<'de> T: Deserialize<'de>, +{ + let input = Signal::new("".to_string()); + + let visible = Signal::new(false); + let hide_dropdown = cloned!((visible, selected_item, input) => move |_| { + visible.set(false); + if selected_item.get().is_none() { + input.set("".to_string()); + } + }); + let item_list: Signal> = Signal::new(vec![]); + let selected = Signal::new(false); + create_effect( + cloned!((input, visible, item_list, selected, selected_item) => move || { + // Early return if: + // - The input is empty, there is nothing to search for nor to show + // - We just selected an item + if input.get().is_empty() || *selected.get_untracked() { + selected.set(false); + visible.set(false); + return; + } + + selected_item.set(None); + let url = "http://localhost:8000/v1/company/"; + if G::IS_BROWSER { + perseus::spawn_local( + cloned!((input, visible, item_list) => async move { + let res = reqwasm::http::Request::get( + &format!( "{}{}?limit={}", url, input.get(), 5) + ) + .send() + .await + .unwrap() + .json::>() + .await + .unwrap(); + visible.set(!res.is_empty()); + item_list.set(res); + }), + ); + } + }), + ); + + let input2 = input.clone(); + + view! { + input (bind:value=input, class="p-2 w-full rounded-md bg-slate-200 dark:bg-slate-700", on:blur=hide_dropdown) {} + div (class="relative") { + ( + cloned!((visible, item_list, input2, selected_item, selected) => { + if *visible.get() { + view! { + div (class="absolute -top-1 w-80 rounded-b-md dark:bg-slate-700 bg-slate-200") { + ul { + Indexed(IndexedProps { + iterable: item_list.handle(), + template: move |x| { + view! { + li ( + class="w-full p-2 cursor-pointer dark:hover:bg-slate-800 hover:bg-slate-300", + on:mousedown=cloned!( + (x, input2, selected_item, visible, selected) => move |_| { + selected.set(true); + selected_item.set(Some(x.clone())); + input2.set(x.clone().to_select_list_item()); + visible.set(false); + } + ), + ) + { + (x.to_select_list_item()) + } + } + }, + }) + } + } + } + } else { + view!{ p (class="hidden") { "Placeholder" } } + } + }) + ) + } + } +} diff --git a/client/src/components/base_button.rs b/client/src/components/base_button.rs new file mode 100644 index 0000000..68a267a --- /dev/null +++ b/client/src/components/base_button.rs @@ -0,0 +1,29 @@ +use sycamore::prelude::*; + +#[derive(Clone)] +pub struct BaseButtonStateRx { + pub label: ReadSignal, + pub disabled: ReadSignal, + pub clicked: Signal, +} + +#[component(BaseButton)] +pub fn create_component( + BaseButtonStateRx { + label, + disabled, + clicked, + }: BaseButtonStateRx, +) -> View { + let click_event = cloned!((clicked) => move |_| { clicked.set(true) }); + + view! { + button ( + class="my-2 z-0 p-2 bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-800 disabled:cursor-not-allowed hover:cursor-pointer rounded-md ", + disabled=*disabled.get(), + on:click=click_event, + ) { + (label.get()) + } + } +} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs index 0713042..9905cc0 100644 --- a/client/src/components/mod.rs +++ b/client/src/components/mod.rs @@ -1,3 +1,5 @@ +pub mod base_async_select; +pub mod base_button; pub mod base_table; pub mod loading; pub mod paginated_data_table; diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index c7a60df..0700770 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -1,14 +1,15 @@ use std::marker::PhantomData; -use perseus::{Html, RenderFnResult, RenderFnResultWithCause, SsrNode, Template}; -use sycamore::{ - prelude::{view, View}, - reactive::{cloned, Signal}, -}; +use perseus::{navigate, Html, RenderFnResult, RenderFnResultWithCause, SsrNode, Template}; +use sycamore::prelude::*; use crate::{ - api::types::transaction::TransactionCompany, - components::paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + api::types::{company::Company, transaction::TransactionCompany}, + components::{ + base_async_select::{AsyncSelectRx, BaseAsyncSelect}, + base_button::{BaseButton, BaseButtonStateRx}, + paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + }, global_state::AppStateRx, }; @@ -30,6 +31,25 @@ pub fn index_page(IndexPageStateRx { req }: IndexPageStateRx, global_state: AppS ph_data: Signal::new(PhantomData), }; + let async_select_prop: AsyncSelectRx = AsyncSelectRx { + selected_item: Signal::new(None), + }; + let async_select_prop2 = async_select_prop.clone(); + + let search_button = BaseButtonStateRx { + label: Signal::new("Search".to_string()).handle(), + disabled: create_memo(cloned!((async_select_prop) => move || { + async_select_prop.selected_item.get().is_none() + })), + clicked: Signal::new(false), + }; + + create_effect(cloned!((search_button) => move || { + if *search_button.clicked.get() { + navigate(&format!("/index/{}", (*async_select_prop2.selected_item.get()).clone().map_or("".to_string(), |c| c.slug))); + } + })); + view! { main (class=if *dark_mode_3.get() { "dark" } else { "" }) { link (rel="stylesheet", href = "/.perseus/static/tailwind.css") {} @@ -52,10 +72,15 @@ pub fn index_page(IndexPageStateRx { req }: IndexPageStateRx, global_state: AppS a (class="hover:underline", href="/") { h1 ( class="text-center text-lg" - ) { + ) { "Insider Transactions published by the AMF" } } + div (class="w-80") { + p () {"Search for a company:"} + BaseAsyncSelect(async_select_prop) + BaseButton(search_button) + } PaginatedTable(paginated_table_state) } } diff --git a/client/static/tailwind.css b/client/static/tailwind.css index 1df2ad2..3481188 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -513,6 +513,14 @@ video { --tw-backdrop-sepia: ; } +.visible { + visibility: visible; +} + +.\!visible { + visibility: visible !important; +} + .static { position: static; } @@ -521,6 +529,22 @@ video { position: fixed; } +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.-top-1 { + top: -0.25rem; +} + +.z-0 { + z-index: 0; +} + .m-2 { margin: 0.5rem; } @@ -548,6 +572,11 @@ video { margin-right: 0.25rem; } +.my-10 { + margin-top: 2.5rem; + margin-bottom: 2.5rem; +} + .mb-20 { margin-bottom: 5rem; } @@ -556,6 +585,10 @@ video { margin-right: 2.5rem; } +.inline { + display: inline; +} + .flex { display: flex; } @@ -564,6 +597,10 @@ video { display: table; } +.hidden { + display: none; +} + .h-10 { height: 2.5rem; } @@ -572,6 +609,10 @@ video { height: 3rem; } +.h-2 { + height: 0.5rem; +} + .w-full { width: 100%; } @@ -580,6 +621,14 @@ video { width: 80%; } +.w-10 { + width: 2.5rem; +} + +.w-80 { + width: 20rem; +} + .table-auto { table-layout: auto; } @@ -588,6 +637,10 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.cursor-pointer { + cursor: pointer; +} + .flex-row { flex-direction: row; } @@ -624,6 +677,11 @@ video { border-radius: 9999px; } +.rounded-b-md { + border-bottom-right-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + .border { border-width: 1px; } @@ -666,6 +724,11 @@ video { background-color: rgb(251 207 232 / var(--tw-bg-opacity)); } +.bg-slate-300 { + --tw-bg-opacity: 1; + background-color: rgb(203 213 225 / var(--tw-bg-opacity)); +} + .p-2 { padding: 0.5rem; } @@ -739,6 +802,20 @@ video { backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } +.hover\:cursor-pointer:hover { + cursor: pointer; +} + +.hover\:bg-slate-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(203 213 225 / var(--tw-bg-opacity)); +} + +.hover\:bg-slate-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(148 163 184 / var(--tw-bg-opacity)); +} + .hover\:font-bold:hover { font-weight: 700; } @@ -752,6 +829,24 @@ video { text-decoration-line: underline; } +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.disabled\:bg-slate-200:disabled { + --tw-bg-opacity: 1; + background-color: rgb(226 232 240 / var(--tw-bg-opacity)); +} + +.disabled\:text-red-500:disabled { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + .dark .dark\:bg-slate-800 { --tw-bg-opacity: 1; background-color: rgb(30 41 59 / var(--tw-bg-opacity)); @@ -786,11 +881,26 @@ video { color: rgb(165 180 252 / var(--tw-text-opacity)); } +.dark .dark\:hover\:bg-slate-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 41 59 / var(--tw-bg-opacity)); +} + +.dark .dark\:hover\:bg-slate-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(203 213 225 / var(--tw-bg-opacity)); +} + .dark .dark\:hover\:text-indigo-600:hover { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); } +.dark .dark\:disabled\:bg-slate-700:disabled { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + @media (min-width: 640px) { .sm\:p-2 { padding: 0.5rem; diff --git a/makefile b/makefile index 740de7f..3cb6228 100644 --- a/makefile +++ b/makefile @@ -15,3 +15,7 @@ tailwind: serve: cd client && \ perseus serve -w + +client-deploy: + cd client && \ + perseus deploy diff --git a/server/src/route/company.rs b/server/src/route/company.rs index 046f8e6..2ccd2db 100644 --- a/server/src/route/company.rs +++ b/server/src/route/company.rs @@ -1,5 +1,5 @@ use rocket::{http::Status, response::status::Custom}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; use sea_orm_rocket::rocket::serde::json::Json; use sea_orm_rocket::Connection; use serde::{Deserialize, Serialize}; @@ -27,14 +27,16 @@ pub async fn get_all( Ok(Json(res)) } -#[get("/company/")] +#[get("/company/?")] pub async fn get_by_isin( conn: Connection<'_, Db>, name: String, + limit: Option, ) -> Result>, Custom> { let db = conn.into_inner(); let res = company::Entity::find() .filter(company::Column::Name.contains(&name)) + .limit(limit.unwrap_or(10)) .all(db) .await .map_err(|e| {