Use perseus beta.21

pull/25/head
Miroito 3 years ago
parent e6d402bced
commit 60061224af

@ -0,0 +1,2 @@
[build]
rustflags = ["--cfg", "engine"]

872
Cargo.lock generated

File diff suppressed because it is too large Load Diff

2
client/.gitignore vendored

@ -1,2 +1,2 @@
.perseus/ dist/
pkg/ pkg/

@ -9,12 +9,23 @@ edition = "2021"
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
dotenvy = { workspace = true } perseus = { version = "0.4.0-beta.21", features = ["hydrate"] }
envy = { workspace = true } sycamore = { version = "^0.8.1", features = [
perseus = { version = "0.3.6", features = ["hydrate"] } "ssr",
sycamore = { version = "0.7.1", features = ["ssr", "serde", "futures"] } "serde",
reqwasm = "0.5.0" "suspense",
"hydrate",
] }
[target.'cfg(engine)'.dev-dependencies]
fantoccini = "^0.19.3"
[dev-dependencies] [target.'cfg(engine)'.dependencies]
fantoccini = "0.19.3" tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
tokio = { version = "=1.20.1", features = ["macros", "rt", "rt-multi-thread"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
perseus-axum = { version = "=0.4.0-beta.19", features = ["dflt-server"] }
# dotenvy = { workspace = true }
# envy = { workspace = true }
[target.'cfg(client)'.dependencies]
reqwasm = "0.5.0"

@ -1,16 +1,19 @@
use crate::{ use crate::api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany};
api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany},
env::Config,
};
pub async fn get_transactions( pub async fn get_transactions(
company_slug: Option<String>, company_slug: Option<String>,
page: i64, page: i64,
size: i64, size: i64,
) -> Result<PaginatedResponse<TransactionCompany>, ()> { ) -> Result<PaginatedResponse<TransactionCompany>, ()> {
use crate::env::Config;
#[cfg(client)]
{
// TODO: Remove build-time environment variable
let api_url = Config::new().api_url;
let res = reqwasm::http::Request::get(&format!( let res = reqwasm::http::Request::get(&format!(
"{}transaction{}?page={}&size={}", "{}transaction{}?page={}&size={}",
Config::new().api_url, api_url,
company_slug.unwrap_or_default(), company_slug.unwrap_or_default(),
page, page,
size, size,
@ -22,5 +25,24 @@ pub async fn get_transactions(
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
Ok(res) return Ok(res);
}
#[cfg(engine)]
{
let res = reqwest::get(&format!(
"{}transaction{}?page={}&size={}",
Config::new().api_url,
company_slug.unwrap_or_default(),
page,
size,
))
.await
.map_err(|_| ())?
.json::<PaginatedResponse<TransactionCompany>>()
.await
.map_err(|_| ())?;
return Ok(res);
}
} }

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sycamore::{prelude::GenericNode, view}; use sycamore::prelude::*;
use crate::components::base_table::TableContent; use crate::components::base_table::TableContent;
@ -9,7 +9,7 @@ pub trait IntoTableData<G>
where where
G: GenericNode, G: GenericNode,
{ {
fn into_table_data(self) -> TableContent<G>; fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G>;
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@ -23,53 +23,53 @@ impl<G> IntoTableData<G> for PaginatedResponse<TransactionCompany>
where where
G: GenericNode, G: GenericNode,
{ {
fn into_table_data(self) -> TableContent<G> { fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G> {
let headers_view = vec![ let headers_view = vec![
view! { "Company" }, view! {cx, "Company" },
view! { "Date published" }, view! {cx, "Date published" },
view! { "Date executed" }, view! {cx, "Date executed" },
view! { "Person" }, view! {cx, "Person" },
view! { "Nature" }, view! {cx, "Nature" },
view! { "ISIN" }, view! {cx, "ISIN" },
view! { "Instrument" }, view! {cx, "Instrument" },
view! { "Exchange" }, view! {cx, "Exchange" },
view! { "Volume" }, view! {cx, "Volume" },
view! { "Unit price" }, view! {cx, "Unit price" },
view! { "Total" }, view! {cx, "Total" },
]; ];
let data_view: Vec<Vec<view::View<G>>> = self let data_view: Vec<Vec<View<G>>> = self
.list .list
.into_iter() .into_iter()
.map(|t| { .map(|t| {
let mut res = vec![]; let mut res = vec![];
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { res.push(view! {cx,
a (href=format!("index/{}", t1.company.slug), a (href=format!("{}", t1.company.slug),
class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 dark:hover:text-indigo-600 hover:underline", class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 dark:hover:text-indigo-600 hover:underline",
) { ) {
(t1.company.name.to_owned()) (t1.company.name.to_owned())
} }
}); });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.date_published.to_owned()) }); res.push(view! {cx, (t1.date_published.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.date_executed.to_owned()) }); res.push(view! {cx, (t1.date_executed.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.person.to_owned()) }); res.push(view! {cx, (t1.person.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.nature.to_owned()) }); res.push(view! {cx, (t1.nature.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.isin.to_owned().unwrap_or_else(|| "-".to_string())) }); res.push(view! {cx, (t1.isin.to_owned().unwrap_or_else(|| "-".to_string())) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.instrument.to_owned()) }); res.push(view! {cx, (t1.instrument.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.exchange.to_owned()) }); res.push(view! {cx, (t1.exchange.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.volume.to_owned()) }); res.push(view! {cx, (t1.volume.to_owned()) });
let t1 = t.clone(); let t1 = t.clone();
res.push(view! { (t1.unit_price.to_owned()) }); res.push(view! {cx, (t1.unit_price.to_owned()) });
res.push(view! { ((t.volume as f32 * t.unit_price).to_string()) }); res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) });
res res
}) })

@ -1,4 +1,4 @@
use perseus::checkpoint; use perseus::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use sycamore::prelude::*; use sycamore::prelude::*;
@ -6,39 +6,34 @@ pub trait IntoAsyncSelectListItem {
fn to_select_list_item(&self) -> String; fn to_select_list_item(&self) -> String;
} }
#[derive(Clone)] #[derive(Prop)]
pub struct AsyncSelectRx<T> pub struct AsyncSelectRx<'a, T>
where where
T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, T: 'static + PartialEq + Clone + IntoAsyncSelectListItem,
{ {
pub remote_list: ReadSignal<String>, pub remote_list: &'a ReadSignal<String>,
pub selected_item: Signal<Option<T>>, pub selected_item: &'a Signal<Option<T>>,
} }
#[component(BaseAsyncSelect<G>)] #[component]
pub fn create_component<T>( pub fn BaseAsyncSelect<'a, G, T>(cx: Scope<'a>, props: AsyncSelectRx<'a, T>) -> View<G>
AsyncSelectRx {
remote_list,
selected_item,
}: AsyncSelectRx<T>,
) -> View<G>
where where
G: Html,
T: 'static + PartialEq + Clone + IntoAsyncSelectListItem, T: 'static + PartialEq + Clone + IntoAsyncSelectListItem,
for<'de> T: Deserialize<'de>, for<'de> T: Deserialize<'de>,
{ {
let input = Signal::new("".to_string()); let input = create_signal(cx, "".to_string());
let visible = Signal::new(false); let visible = create_signal(cx, false);
let hide_dropdown = cloned!((visible, selected_item, input) => move |_| { let hide_dropdown = move |_| {
visible.set(false); visible.set(false);
if selected_item.get().is_none() { if props.selected_item.get().is_none() {
input.set("".to_string()); input.set("".to_string());
} }
}); };
let item_list: Signal<Vec<T>> = Signal::new(vec![]); let item_list: &Signal<Vec<T>> = create_signal(cx, vec![]);
let selected = Signal::new(false); let selected = create_signal(cx, false);
create_effect( create_effect(cx, move || {
cloned!((input, visible, item_list, selected, selected_item, remote_list) => move || {
// Early return if: // Early return if:
// - The input is empty, there is nothing to search for nor to show // - The input is empty, there is nothing to search for nor to show
// - We just selected an item // - We just selected an item
@ -48,13 +43,16 @@ where
return; return;
} }
selected_item.set(None); props.selected_item.set(None);
if G::IS_BROWSER {
perseus::spawn_local( #[cfg(client)]
cloned!((input, visible, item_list, remote_list) => async move { spawn_local_scoped(cx, async move {
let res = reqwasm::http::Request::get( let res = reqwasm::http::Request::get(&format!(
&format!( "{}/{}?limit={}", remote_list.get(), input.get(), 5) "{}/{}?limit={}",
) props.remote_list.get(),
input.get(),
5
))
.send() .send()
.await .await
.unwrap() .unwrap()
@ -64,39 +62,34 @@ where
visible.set(!res.is_empty()); visible.set(!res.is_empty());
item_list.set(res); item_list.set(res);
checkpoint("async_select_item_change"); checkpoint("async_select_item_change");
}), });
); });
}
}),
);
let input2 = input.clone();
let visible2 = visible.clone();
view! { view! { cx,
input (bind:value=input, class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800", on:blur=hide_dropdown) {} input (bind:value=input, class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800", on:blur=hide_dropdown) {}
div (class="relative") { div (class="relative") {
div (class=format!("absolute -top-1 w-80 rounded-b-md dark:bg-slate-800 bg-slate-300 {}", if *visible.get() { "visible" } else { "collapse" })) { div (class=format!("absolute -top-1 w-80 rounded-b-md dark:bg-slate-800 bg-slate-300 {}", if *visible.get() { "visible" } else { "collapse" })) {
ul { ul {
Indexed(IndexedProps { Indexed(
iterable: item_list.handle(), iterable=item_list,
template: move |x| { view= move |cx, x| {
view! { let item = x.clone();
view! {cx,
li ( li (
class="w-full p-2 cursor-pointer dark:hover:bg-slate-900 hover:bg-slate-400", class="w-full p-2 cursor-pointer dark:hover:bg-slate-900 hover:bg-slate-400",
on:mousedown=cloned!((x, input2, selected_item, visible2, selected) => move |_| { on:mousedown=move |_| {
selected.set(true); selected.set(true);
selected_item.set(Some(x.clone())); props.selected_item.set(Some(item.clone()));
input2.set(x.clone().to_select_list_item()); input.set(item.to_select_list_item());
visible2.set(false); visible.set(false);
}), },
) )
{ {
(x.to_select_list_item()) (x.to_select_list_item())
} }
} }
}, },
}) )
} }
} }
} }

@ -1,29 +1,23 @@
use sycamore::prelude::*; use sycamore::prelude::*;
#[derive(Clone)] #[derive(Prop)]
pub struct BaseButtonStateRx { pub struct BaseButtonStateRx<'a> {
pub label: ReadSignal<String>, pub label: &'a ReadSignal<String>,
pub disabled: ReadSignal<bool>, pub disabled: &'a ReadSignal<bool>,
pub clicked: Signal<bool>, pub clicked: &'a Signal<bool>,
} }
#[component(BaseButton<G>)] #[component]
pub fn create_component( pub fn BaseButton<'a, G: Html>(cx: Scope<'a>, props: BaseButtonStateRx<'a>) -> View<G> {
BaseButtonStateRx { let click_event = |_| props.clicked.set(true);
label,
disabled,
clicked,
}: BaseButtonStateRx,
) -> View<G> {
let click_event = cloned!((clicked) => move |_| { clicked.set(true) });
view! { view! { cx,
button ( button (
class="my-2 z-0 p-2 bg-slate-300 dark:bg-slate-800 hover:bg-slate-400 dark:hover:bg-slate-900 disabled:cursor-not-allowed hover:cursor-pointer rounded-md ", class="my-2 z-0 p-2 bg-slate-300 dark:bg-slate-800 hover:bg-slate-400 dark:hover:bg-slate-900 disabled:cursor-not-allowed hover:cursor-pointer rounded-md ",
disabled=*disabled.get(), disabled=*props.disabled.get(),
on:click=click_event, on:click=click_event,
) { ) {
(label.get()) (props.label.get())
} }
} }
} }

@ -9,13 +9,13 @@ where
pub data_view: Vec<Vec<View<G>>>, pub data_view: Vec<Vec<View<G>>>,
} }
#[derive(Debug, Clone)] #[derive(Prop)]
pub struct TableContentRx<G> pub struct TableContentRx<'a, G>
where where
G: GenericNode, G: GenericNode,
{ {
pub headers_view: Signal<Vec<View<G>>>, pub headers_view: &'a Signal<Vec<View<G>>>,
pub data_view: Signal<Vec<Vec<View<G>>>>, pub data_view: &'a Signal<Vec<Vec<View<G>>>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -29,65 +29,64 @@ impl<T> PartialEq<PEqView<T>> for PEqView<T> {
impl<T> Eq for PEqView<T> {} impl<T> Eq for PEqView<T> {}
#[component(BaseTable<G>)] #[component]
pub fn the_header( pub fn BaseTable<'a, G>(cx: Scope<'a>, props: TableContentRx<'a, G>) -> View<G>
TableContentRx {
headers_view,
data_view,
}: TableContentRx<G>,
) -> View<G>
where where
G: GenericNode, G: Html,
{ {
let headers = create_memo(cloned!((headers_view) => move || { let headers = create_memo(cx, || {
(*headers_view.get()).clone().into_iter().enumerate().map(|(idx, v)| PEqView(idx,v)).collect::<Vec<PEqView<View<G>>>>() (*props.headers_view.get())
})); .clone()
.into_iter()
.enumerate()
.map(|(idx, v)| PEqView(idx, v))
.collect::<Vec<PEqView<View<G>>>>()
});
let data = Signal::new(vec![]); let data = create_signal(cx, vec![]);
let data2 = data.clone(); create_effect(cx, move || {
create_effect(cloned!((data_view, data2, data) => move || {
data.set(vec![]); data.set(vec![]);
let v_table = data_view.get(); let v_table = props.data_view.get();
for (idx, row) in v_table.iter().enumerate() { for (idx, row) in v_table.iter().enumerate() {
let views = PEqView(idx, View::new_fragment( let views = PEqView(idx, View::new_fragment(
row.iter().map(|cell| { row.iter().map(|cell| {
view!{ th (class="m-2 p-2 border-slate-500 border-x border-dashed") { (cell) } } view!{cx, th (class="m-2 p-2 border-slate-500 border-x border-dashed") { (cell) } }
} ).collect() } ).collect()
)); ));
let mut d = (*data2.get()).clone(); let mut d = (*data.get()).clone();
d.push(views); d.push(views);
data.set(d); data.set(d.to_vec());
} }
})); });
view! { view! { cx,
table (class="table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2") { table (class="table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2") {
thead { thead {
tr (class="border-b-2 border-slate-500 text-center") { tr (class="border-b-2 border-slate-500 text-center") {
Keyed(KeyedProps { Keyed(
iterable: headers, iterable=headers,
template: |v| { view=|cx, v| {
view! { view! {cx,
th (class="m-2 p-2") { (v.1) } th (class="m-2 p-2") { (v.1) }
} }
}, },
key: |v| v.0, key=|v| v.0,
}) )
} }
} }
tbody { tbody {
Keyed(KeyedProps { Keyed(
iterable: data.handle(), iterable=data,
key: |x| x.0, view=|cx, t| {
template: |t| { view! {cx,
view! {
tr (class="m-2 p-2 border-slate-500 border") { tr (class="m-2 p-2 border-slate-500 border") {
(t.1) (t.1)
} }
} }
}, },
}) key=|x| x.0,
)
} }
} }
} }

@ -1,8 +1,8 @@
use sycamore::prelude::*; use sycamore::prelude::*;
#[component(Loading<G>)] #[component]
pub fn component() -> View<G> { pub fn Loading<G: Html>(cx: Scope) -> View<G> {
view! { view! { cx,
svg (version="1.1", id="loader-1", xmlns="http://www.w3.org/2000/svg", xlink="http://www.w3.org/1999/xlink", x="0px", y="0px", svg (version="1.1", id="loader-1", xmlns="http://www.w3.org/2000/svg", xlink="http://www.w3.org/1999/xlink", x="0px", y="0px",
width="40px", height="40px", viewBox="0 0 50 50", space="preserve") { width="40px", height="40px", viewBox="0 0 50 50", space="preserve") {
path (fill="#000", d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z"){ path (fill="#000", d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z"){

@ -1,5 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use perseus::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use sycamore::prelude::*; use sycamore::prelude::*;
@ -11,7 +12,7 @@ use crate::{
}, },
}; };
#[derive(Clone)] #[derive(Prop)]
pub struct PaginatedTableStateRx<M, F, C> pub struct PaginatedTableStateRx<M, F, C>
where where
M: 'static, M: 'static,
@ -33,64 +34,68 @@ where
} }
} }
#[component(PaginatedTable<G>)] #[component]
pub fn component<M, F, C>(state: PaginatedTableStateRx<M, F, C>) -> View<G> pub fn PaginatedTable<'a, G, M, F, C>(
cx: Scope<'a>,
props: PaginatedTableStateRx<M, F, C>,
) -> View<G>
where where
G: Html,
M: 'static + Clone, M: 'static + Clone,
PaginatedResponse<M>: IntoTableData<G>, PaginatedResponse<M>: IntoTableData<G>,
for<'de> M: Deserialize<'de>, for<'de> M: Deserialize<'de>,
C: Fn(Option<String>, i64, i64) -> F + 'static, C: Fn(Option<String>, i64, i64) -> F + 'static,
F: std::future::Future<Output = Result<PaginatedResponse<M>, ()>> + 'static, F: std::future::Future<Output = Result<PaginatedResponse<M>, ()>> + 'static,
{ {
let paginated_data: Signal<Option<PaginatedResponse<M>>> = Signal::new(None); let paginated_data = create_signal(cx, None);
let table_prop: TableContentRx<G> = TableContentRx { let table_prop: TableContentRx<G> = TableContentRx {
headers_view: Signal::new(vec![]), headers_view: create_signal(cx, vec![]),
data_view: Signal::new(vec![vec![]]), data_view: create_signal(cx, vec![vec![]]),
}; };
let table_prop2 = table_prop.clone(); let page = create_signal(cx, 0);
let page: Signal<i64> = Signal::new(0); let n_page = create_signal(cx, 1);
let n_page: Signal<i64> = Signal::new(1); let n_rows = create_signal(cx, 0);
let n_rows: Signal<i64> = Signal::new(0);
let page_up = cloned!((page, paginated_data, n_page) => move |_| { let page_up = move |_| {
n_page.set((*paginated_data.get()).as_ref().map_or(0, |t| t.num_pages)); n_page.set(
(*paginated_data.get())
.clone()
.map_or(0, |t: PaginatedResponse<M>| t.num_pages),
);
if *page.get() + 1 < *n_page.get() { if *page.get() + 1 < *n_page.get() {
page.set((*page.get()).min(*n_page.get() - 1) + 1) page.set((*page.get()).min(*n_page.get() - 1) + 1)
} }
}); };
let page_down = cloned!((page) => move |_| { let page_down = |_| {
if *page.get() > 0 { if *page.get() > 0 {
page.set((*page.get()-1).max(0)); page.set((*page.get() - 1).max(0));
} }
}); };
let page_size_string = Signal::new("20".to_string()); let page_size_string = create_signal(cx, "20".to_string());
let page_size_string2 = page_size_string.clone(); let page_size_string2 = page_size_string.clone();
let state_rc = Rc::new(state);
create_effect( //let props_ref = Rc::new(props);
cloned!((page_size_string, paginated_data, page, n_page, n_rows, state_rc) => move || { let props_sig = create_signal(cx, props);
create_effect(cx, move || {
let page = *page.get(); let page = *page.get();
let page_size_s = page_size_string.get(); let page_size_s = page_size_string.get();
let page_size = page_size_s.parse().unwrap_or(20); let page_size = page_size_s.parse().unwrap_or(20);
if G::IS_BROWSER { #[cfg(client)]
perseus::spawn_local( spawn_local_scoped(cx, async move {
cloned!((table_prop2, page, paginated_data, n_page, n_rows, state_rc) => async move { let res = props_sig.get().get_data(page, page_size).await.unwrap();
let res = state_rc.get_data(page, page_size).await.unwrap();
paginated_data.set(Some(res.clone())); paginated_data.set(Some(res.clone()));
n_rows.set(res.count); n_rows.set(res.count);
let table_content = res.into_table_data(); let table_content = res.into_table_data(cx);
table_prop2.data_view.set(table_content.data_view); table_prop.data_view.set(table_content.data_view);
table_prop2.headers_view.set(table_content.headers_view); table_prop.headers_view.set(table_content.headers_view);
n_page.set((*paginated_data.get()).as_ref().map_or(0, |t| t.num_pages)); n_page.set((*paginated_data.get()).as_ref().map_or(0, |t| t.num_pages));
}), });
); });
}
}),
);
view! { view! { cx,
(cloned!((n_rows, page_size_string, page_down, page_up, page, n_page, page_size_string2) => (if paginated_data.get().is_some() {
view! { view! {cx,
p (class="text-right") { (format!("{} transactions", n_rows.get())) } p (class="text-right") { (format!("{} transactions", n_rows.get())) }
div (class="flex flex-row justify-between") { div (class="flex flex-row justify-between") {
select (bind:value=page_size_string, select (bind:value=page_size_string,
@ -115,13 +120,10 @@ where
} }
} }
} }
})) BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view)
(if paginated_data.get().is_some() {
view! {
BaseTable(table_prop.clone())
} }
} else { } else {
view! { view! {cx,
div (class="flex flex-row justify-center") { div (class="flex flex-row justify-center") {
Loading() Loading()
} }

@ -1,8 +1,8 @@
use sycamore::prelude::*; use sycamore::prelude::*;
#[component(TheHeader<G>)] #[component]
pub fn the_header(_: ()) -> View<G> { pub fn the_header<G: Html>(cx: Scope) -> View<G> {
view! { view! { cx,
"Don't use until global state is recheable from a component" "Don't use until global state is recheable from a component"
// header (class="shadow-md h-10 sm:p-2 w-full mb-20 bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { // header (class="shadow-md h-10 sm:p-2 w-full mb-20 bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") {
// nav () { // nav () {

@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; // use std::env;
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub api_url: String, pub api_url: String,
} }
#[cfg(engine)]
impl Config { impl Config {
pub fn new() -> Self { pub fn new() -> Self {
let api_url = env!("API_URL").to_string(); let api_url = env!("API_URL").to_string();
@ -14,6 +15,15 @@ impl Config {
} }
} }
#[cfg(client)]
impl Config {
pub fn new() -> Self {
let api_url = env!("API_URL").to_string();
Config { api_url }
}
}
#[cfg(engine)]
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Config::new() Config::new()

@ -1,17 +1,62 @@
use perseus::{ErrorPages, Html}; use perseus::errors::ClientError;
use sycamore::view; use perseus::prelude::*;
use sycamore::prelude::*;
pub fn get_error_pages<G: Html>() -> ErrorPages<G> { pub fn get_error_views<G: Html>() -> ErrorViews<G> {
let mut error_pages = ErrorPages::new(|url, status, err, _| { ErrorViews::new(|cx, err, _err_info, _err_pos| {
view! { match err {
p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } ClientError::ServerError { status, message: _ } => match status {
404 => (
view! { cx,
title { "Page not found" }
},
view! { cx,
p { "Sorry, that page doesn't seem to exist." }
},
),
// 4xx is a client error
_ if (400..500).contains(&status) => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "There was something wrong with the last request, please try reloading the page." }
},
),
// 5xx is a server error
_ => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "Sorry, our server experienced an internal error. Please try reloading the page." }
},
),
},
ClientError::Panic(_) => (
view! { cx,
title { "Critical error" }
},
view! { cx,
p { "A critical error occured" }
},
),
ClientError::FetchError(_) => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "A network error occurred, do you have an internet connection? (If you do, try reloading the page.)" }
},
),
_ => (
view! { cx,
title { "Error" }
},
view! { cx,
p { (format!("An internal error has occurred: '{}'.", err)) }
},
),
} }
}); })
error_pages.add_page(404, |_, _, _, _| {
view! {
p { "Page not found." }
}
});
error_pages
} }

@ -1,21 +1,26 @@
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use crate::env::Config; use crate::env::Config;
use perseus::{state::GlobalStateCreator, RenderFnResult}; use perseus::state::GlobalStateCreator;
pub fn get_global_state_creator() -> GlobalStateCreator { pub fn get_global_state_creator() -> GlobalStateCreator {
GlobalStateCreator::new().build_state_fn(get_build_state) GlobalStateCreator::new().build_state_fn(get_build_state)
} }
#[perseus::make_rx(AppStateRx)] #[derive(Serialize, Deserialize, ReactiveState)]
#[rx(alias = "AppStateRx")]
pub struct AppState { pub struct AppState {
pub dark_mode: bool, pub dark_mode: bool,
pub config: Config, pub config: Config,
} }
#[perseus::autoserde(global_build_state)] #[engine_only_fn]
pub async fn get_build_state() -> RenderFnResult<AppState> { pub async fn get_build_state(_locale: String) -> AppState {
use crate::env::Config;
let config = Config::new(); let config = Config::new();
Ok(AppState { AppState {
config, config,
dark_mode: true, dark_mode: true,
}) }
} }

@ -1,4 +1,4 @@
use perseus::{Html, PerseusApp, PerseusRoot}; use perseus::prelude::*;
use sycamore::view; use sycamore::view;
mod api; mod api;
@ -8,14 +8,14 @@ pub mod error_pages;
pub mod global_state; pub mod global_state;
pub mod templates; pub mod templates;
#[perseus::main] #[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> { pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new() PerseusApp::new()
.template(crate::templates::index::get_template) .template(crate::templates::index::get_template())
.global_state_creator(crate::global_state::get_global_state_creator()) .global_state_creator(crate::global_state::get_global_state_creator())
.error_pages(crate::error_pages::get_error_pages) .error_views(crate::error_pages::get_error_views())
.index_view(|| { .index_view(|cx| {
view! { view! {cx,
head() { head() {
title { "Fast Insiders" } title { "Fast Insiders" }
link (rel="stylesheet", href = "/.perseus/static/tailwind.css") {} link (rel="stylesheet", href = "/.perseus/static/tailwind.css") {}

@ -1,11 +1,10 @@
use perseus::{navigate, Html, RenderFnResult, RenderFnResultWithCause, SsrNode, Template}; use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::{ use crate::{
api::{ api::routes::transaction::get_transactions,
routes::transaction::get_transactions, api::types::{company::Company, transaction::TransactionCompany},
types::{company::Company, transaction::TransactionCompany},
},
components::{ components::{
base_async_select::{AsyncSelectRx, BaseAsyncSelect}, base_async_select::{AsyncSelectRx, BaseAsyncSelect},
base_button::{BaseButton, BaseButtonStateRx}, base_button::{BaseButton, BaseButtonStateRx},
@ -14,68 +13,69 @@ use crate::{
global_state::AppStateRx, global_state::AppStateRx,
}; };
#[perseus::make_rx(IndexPageStateRx)] #[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "IndexPageStateRx")]
pub struct IndexPageState { pub struct IndexPageState {
pub company_slug: String, pub company_slug: String,
} }
#[perseus::template_rx] #[auto_scope]
pub fn index_page( fn index_page<G: Html>(cx: Scope, state: &IndexPageStateRx) -> View<G> {
IndexPageStateRx { company_slug }: IndexPageStateRx, let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
global_state: AppStateRx, let dark_mode = &global_state.dark_mode;
) -> View<G> {
let dark_mode = global_state.dark_mode;
let dark_mode_2 = dark_mode.clone(); let dark_mode_2 = dark_mode.clone();
let dark_mode_3 = dark_mode.clone(); let dark_mode_3 = dark_mode.clone();
let expand = Signal::new(false); let expand = create_signal(cx, false);
let filter_expand = BaseButtonStateRx { let filter_expand = BaseButtonStateRx {
label: Signal::new("Filters".to_string()).handle(), label: create_signal(cx, "Filters".to_string()),
disabled: Signal::new(false).handle(), disabled: create_signal(cx, false),
clicked: Signal::new(false), clicked: create_signal(cx, false),
}; };
create_effect(cloned!((filter_expand, expand) => move || { create_effect(cx, move || {
if *filter_expand.clicked.get() { if *filter_expand.clicked.get() {
filter_expand.clicked.set(false); filter_expand.clicked.set(false);
expand.set(!*expand.get()); expand.set(!*expand.get());
} }
})); });
let toggle_dark_mode = cloned!(() => move |_| dark_mode_2.set(!*dark_mode.get())); let toggle_dark_mode = move |_| dark_mode_2.set(!*dark_mode.get());
let paginated_table_state: PaginatedTableStateRx<TransactionCompany, _, _> = let paginated_table_state: PaginatedTableStateRx<TransactionCompany, _, _> =
PaginatedTableStateRx { PaginatedTableStateRx {
route: get_transactions, route: get_transactions,
filter: if (*company_slug.get()).is_empty() { filter: if (*state.company_slug.get()).is_empty() {
None None
} else { } else {
Some((*company_slug.get()).clone()) Some((*state.company_slug.get()).clone())
}, },
}; };
let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx { let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx {
remote_list: Signal::new(format!("{}company/", global_state.config.get().api_url)).handle(), remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")),
selected_item: Signal::new(None), selected_item: create_signal(cx, None),
}; };
let async_select_prop2 = async_select_prop.clone();
let search_button = BaseButtonStateRx { let search_button = BaseButtonStateRx {
label: Signal::new("Search".to_string()).handle(), label: create_signal(cx, "Search".to_string()),
disabled: create_memo(cloned!((async_select_prop) => move || { disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()),
async_select_prop.selected_item.get().is_none() clicked: create_signal(cx, false),
})),
clicked: Signal::new(false),
}; };
create_effect(cloned!((search_button) => move || { create_effect(cx, || {
if *search_button.clicked.get() { if *search_button.clicked.get() {
search_button.clicked.set(false); search_button.clicked.set(false);
navigate(&format!("/index/{}", (*async_select_prop2.selected_item.get()).clone().map_or("".to_string(), |c| c.slug))); navigate(&format!(
} "/{}",
})); (*async_select_prop.selected_item.get())
.clone()
.map_or("".to_string(), |c| c.slug)
));
}
});
view! { view! {cx,
main (class=if *dark_mode_3.get() { "dark" } else { "" }) { main (class=if *dark_mode_3.get() { "dark" } else { "" }) {
div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") { div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") {
header (class="shadow-md h-12 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { header (class="shadow-md h-12 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") {
@ -101,7 +101,7 @@ pub fn index_page(
} }
} }
BaseButton(filter_expand) BaseButton(filter_expand)
div () {} // Without this useless div, the code doesn't run in the browser div {} // Without this useless div, the code doesn't run in the browser
div (id="filters", class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", div (id="filters", class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}",
if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, if *expand.get() { "h-40 visible" } else { "h-0 collapse" },
) )
@ -122,30 +122,38 @@ pub fn index_page(
} }
pub fn get_template<G: Html>() -> Template<G> { pub fn get_template<G: Html>() -> Template<G> {
Template::new("index") Template::build("index")
.build_paths_fn(get_build_paths) .head(head)
.build_state_fn(get_build_state) .build_state_fn(get_build_state)
.template(index_page) .build_paths_fn(get_build_paths)
.incremental_generation() .incremental_generation()
.head(head) .view_with_state(index_page)
.build()
} }
#[perseus::head] #[engine_only_fn]
pub fn head(_props: IndexPageState) -> View<SsrNode> { fn head(cx: Scope) -> View<SsrNode> {
view! { view! {cx,
title { "Fast Insiders" } title { "Fast Insiders" }
} }
} }
#[perseus::autoserde(build_state)] #[engine_only_fn]
pub async fn get_build_state( async fn get_build_state(
path: String, StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
_locale: String, ) -> Result<IndexPageState, BlamedError<std::io::Error>> {
) -> RenderFnResultWithCause<IndexPageState> { let company_slug: String = path
let company_slug: String = path.clone().drain("index".len()..).collect(); .split("transactions")
.nth(1)
.unwrap_or(&("/".to_owned() + &path))
.to_string();
Ok(IndexPageState { company_slug }) Ok(IndexPageState { company_slug })
} }
pub async fn get_build_paths() -> RenderFnResult<Vec<String>> { #[engine_only_fn]
Ok(vec!["".to_string()]) async fn get_build_paths() -> BuildPaths {
BuildPaths {
paths: vec!["".to_string()],
extra: ().into(),
}
} }

@ -1,10 +1,5 @@
use fantoccini::{ use fantoccini::{Client, Locator};
actions::{InputSource, KeyAction, KeyActions},
error::CmdError,
Client, Locator,
};
use perseus::wait_for_checkpoint; use perseus::wait_for_checkpoint;
use std::time::Duration;
// This is is an E2E test with the following steps: // This is is an E2E test with the following steps:
// - Go to the index page // - Go to the index page
@ -27,12 +22,11 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
let url = c.current_url().await?; let url = c.current_url().await?;
assert!(url.as_ref().starts_with("http://localhost:8080")); assert!(url.as_ref().starts_with("http://localhost:8080"));
wait_for_checkpoint!("page_visible", 0, c); wait_for_checkpoint!("page_interactive", 0, c);
// Verify the page title // Verify the page title
let title = c.find(Locator::Css("title")).await?.html(false).await?; let title = c.find(Locator::Css("title")).await?.html(false).await?;
assert!(title.contains("Fast Insiders")); assert!(title.contains("Fast Insiders"));
wait_for_checkpoint!("page_interactive", 0, c);
// let table = c.find(Locator::Css("table")).await?.html(true).await?; // let table = c.find(Locator::Css("table")).await?.html(true).await?;
let filter_button = c.find(Locator::Css("#main button")).await?; let filter_button = c.find(Locator::Css("#main button")).await?;
filter_button.click().await?; filter_button.click().await?;
@ -50,10 +44,11 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
search_button.click().await?; search_button.click().await?;
let page = c.current_url().await?; let page = c.current_url().await?;
assert!( assert!(
page.as_ref().starts_with("http://localhost:8080/index/"), page.as_ref().starts_with("http://localhost:8080/"),
"Unexpected target url reached: {}", "Unexpected target url reached: {}",
page page
); );
wait_for_checkpoint!("page_interactive", 1, c);
for a in c.find_all(Locator::Css("table a")).await? { for a in c.find_all(Locator::Css("table a")).await? {
let a_text = a.text().await?; let a_text = a.text().await?;
assert_eq!(clicked_company, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", clicked_company, a_text); assert_eq!(clicked_company, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", clicked_company, a_text);
@ -70,7 +65,7 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
assert_eq!(company_name, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", company_name, a_text); assert_eq!(company_name, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", company_name, a_text);
} }
assert!( assert!(
page.as_ref().starts_with("http://localhost:8080/index/"), page.as_ref().starts_with("http://localhost:8080/"),
"Unexpected target url reached: {}", "Unexpected target url reached: {}",
page page
); );

@ -24,12 +24,8 @@ client-deploy:
test-client: test-client:
cd client && \ cd client && \
perseus test -w perseus test --show-browser
test-server: test-server:
cd server && \ cd server && \
cargo test cargo test
test-client-cargo:
cd client && \
PERSEUS_RUN_WASM_TESTS=true cargo test -- --test-threads 1

@ -8,7 +8,6 @@ edition = "2021"
[dependencies] [dependencies]
migration = { version = "0.1.0", path = "./migration" } migration = { version = "0.1.0", path = "./migration" }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
perseus = { version = "0.3.6", features = ["hydrate"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
dotenvy = { workspace = true } dotenvy = { workspace = true }

Loading…
Cancel
Save