Merge pull request 'Update to Perseus beta 21!' (#25) from perseus-beta into master

Reviewed-on: #25
pull/26/head
alban 3 years ago
commit 188e9efefb

@ -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/

@ -9,12 +9,23 @@ edition = "2021"
chrono = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
dotenvy = { workspace = true }
envy = { workspace = true }
perseus = { version = "0.3.6", features = ["hydrate"] }
sycamore = { version = "0.7.1", features = ["ssr", "serde", "futures"] }
reqwasm = "0.5.0"
perseus = { version = "0.4.0-beta.21", features = ["hydrate"] }
sycamore = { version = "^0.8.1", features = [
"ssr",
"serde",
"suspense",
"hydrate",
] }
[target.'cfg(engine)'.dev-dependencies]
fantoccini = "^0.19.3"
[dev-dependencies]
fantoccini = "0.19.3"
tokio = { version = "=1.20.1", features = ["macros", "rt", "rt-multi-thread"] }
[target.'cfg(engine)'.dependencies]
tokio = { version = "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"

@ -4,13 +4,13 @@ FROM rust:1.66-slim AS build
# install build dependencies
RUN apt update \
&& apt install -y --no-install-recommends lsb-release apt-transport-https \
build-essential curl wget pkg-config
build-essential curl wget pkg-config libssl-dev
# vars
ENV PERSEUS_VERSION=0.3.6 \
ENV PERSEUS_VERSION=0.4.0-beta.21 \
PERSEUS_SIZE_OPT_VERSION=0.1.9 \
ESBUILD_VERSION=0.14.7 \
BINARYEN_VERSION=104
BINARYEN_VERSION=112
# prepare root project dir
WORKDIR /app
@ -32,24 +32,22 @@ WORKDIR /app/client
RUN cargo install perseus-cli --version $PERSEUS_VERSION
# clean and prep app
RUN perseus clean && perseus prep
RUN perseus clean
# specify deps in app config
RUN sed -i s"/perseus = .*/perseus = \"${PERSEUS_VERSION}\"/" ./Cargo.toml \
&& sed -i s"/perseus-size-opt = .*/perseus-size-opt = \"${PERSEUS_SIZE_OPT_VERSION}\"/" ./Cargo.toml \
&& cat ./Cargo.toml
# modify lib.rs
RUN sed -i s'/SizeOpts::default()/SizeOpts { wee_alloc: true, lto: true, opt_level: "s".to_string(), codegen_units: 1, enable_fluent_bundle_patch: false, }/' ./src/lib.rs \
&& cat ./src/lib.rs
# modify main.rs
RUN sed -i s'/SizeOpts::default()/SizeOpts { wee_alloc: true, lto: true, opt_level: "s".to_string(), codegen_units: 1, enable_fluent_bundle_patch: false, }/' ./src/main.rs \
&& cat ./src/main.rs
ARG API_URL
ENV API_URL $API_URL
# run plugin(s) to adjust app
RUN perseus tinker \
&& cat .perseus/Cargo.toml \
&& cat ./src/lib.rs
RUN perseus tinker
# single-threaded perseus CLI mode required for low memory environments
#ENV PERSEUS_CLI_SEQUENTIAL=true
@ -75,8 +73,7 @@ RUN wget -nv https://github.com/WebAssembly/binaryen/releases/download/version_$
&& ./binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt --version
# run wasm-opt against bundle.wasm
RUN ./binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt -Os ./client/pkg/dist/pkg/perseus_engine_bg.wasm -o ./client/pkg/dist/pkg/perseus_engine_bg.wasm \
&& ls -lha ./client/pkg/dist/pkg
RUN ./binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt -Os ./client/pkg/dist/pkg/perseus_engine_bg.wasm -o ./client/pkg/dist/pkg/perseus_engine_bg.wasm
# prepare deployment image
FROM debian:stable-slim
@ -88,4 +85,3 @@ COPY --from=build /app/client/pkg /app/
ENV HOST=0.0.0.0
CMD ./server

@ -1,16 +1,19 @@
use crate::{
api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany},
env::Config,
};
use crate::api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany};
pub async fn get_transactions(
company_slug: Option<String>,
page: i64,
size: i64,
) -> 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!(
"{}transaction{}?page={}&size={}",
Config::new().api_url,
api_url,
company_slug.unwrap_or_default(),
page,
size,
@ -22,5 +25,24 @@ pub async fn get_transactions(
.await
.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 sycamore::{prelude::GenericNode, view};
use sycamore::prelude::*;
use crate::components::base_table::TableContent;
@ -9,7 +9,7 @@ pub trait IntoTableData<G>
where
G: GenericNode,
{
fn into_table_data(self) -> TableContent<G>;
fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G>;
}
#[derive(Clone, Serialize, Deserialize)]
@ -23,53 +23,53 @@ impl<G> IntoTableData<G> for PaginatedResponse<TransactionCompany>
where
G: GenericNode,
{
fn into_table_data(self) -> TableContent<G> {
fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G> {
let headers_view = vec![
view! { "Company" },
view! { "Date published" },
view! { "Date executed" },
view! { "Person" },
view! { "Nature" },
view! { "ISIN" },
view! { "Instrument" },
view! { "Exchange" },
view! { "Volume" },
view! { "Unit price" },
view! { "Total" },
view! {cx, "Company" },
view! {cx, "Date published" },
view! {cx, "Date executed" },
view! {cx, "Person" },
view! {cx, "Nature" },
view! {cx, "ISIN" },
view! {cx, "Instrument" },
view! {cx, "Exchange" },
view! {cx, "Volume" },
view! {cx, "Unit price" },
view! {cx, "Total" },
];
let data_view: Vec<Vec<view::View<G>>> = self
let data_view: Vec<Vec<View<G>>> = self
.list
.into_iter()
.map(|t| {
let mut res = vec![];
let t1 = t.clone();
res.push(view! {
a (href=format!("index/{}", t1.company.slug),
res.push(view! {cx,
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",
) {
(t1.company.name.to_owned())
}
});
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();
res.push(view! { (t1.date_executed.to_owned()) });
res.push(view! {cx, (t1.date_executed.to_owned()) });
let t1 = t.clone();
res.push(view! { (t1.person.to_owned()) });
res.push(view! {cx, (t1.person.to_owned()) });
let t1 = t.clone();
res.push(view! { (t1.nature.to_owned()) });
res.push(view! {cx, (t1.nature.to_owned()) });
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();
res.push(view! { (t1.instrument.to_owned()) });
res.push(view! {cx, (t1.instrument.to_owned()) });
let t1 = t.clone();
res.push(view! { (t1.exchange.to_owned()) });
res.push(view! {cx, (t1.exchange.to_owned()) });
let t1 = t.clone();
res.push(view! { (t1.volume.to_owned()) });
res.push(view! {cx, (t1.volume.to_owned()) });
let t1 = t.clone();
res.push(view! { (t1.unit_price.to_owned()) });
res.push(view! { ((t.volume as f32 * t.unit_price).to_string()) });
res.push(view! {cx, (t1.unit_price.to_owned()) });
res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) });
res
})

@ -1,4 +1,4 @@
use perseus::checkpoint;
use perseus::prelude::*;
use serde::Deserialize;
use sycamore::prelude::*;
@ -6,39 +6,34 @@ pub trait IntoAsyncSelectListItem {
fn to_select_list_item(&self) -> String;
}
#[derive(Clone)]
pub struct AsyncSelectRx<T>
#[derive(Prop)]
pub struct AsyncSelectRx<'a, T>
where
T: 'static + PartialEq + Clone + IntoAsyncSelectListItem,
{
pub remote_list: ReadSignal<String>,
pub selected_item: Signal<Option<T>>,
pub remote_list: &'a ReadSignal<String>,
pub selected_item: &'a Signal<Option<T>>,
}
#[component(BaseAsyncSelect<G>)]
pub fn create_component<T>(
AsyncSelectRx {
remote_list,
selected_item,
}: AsyncSelectRx<T>,
) -> View<G>
#[component]
pub fn BaseAsyncSelect<'a, G, T>(cx: Scope<'a>, props: AsyncSelectRx<'a, T>) -> View<G>
where
G: Html,
T: 'static + PartialEq + Clone + IntoAsyncSelectListItem,
for<'de> T: Deserialize<'de>,
{
let input = Signal::new("".to_string());
let input = create_signal(cx, "".to_string());
let visible = Signal::new(false);
let hide_dropdown = cloned!((visible, selected_item, input) => move |_| {
let visible = create_signal(cx, false);
let hide_dropdown = move |_| {
visible.set(false);
if selected_item.get().is_none() {
if props.selected_item.get().is_none() {
input.set("".to_string());
}
});
let item_list: Signal<Vec<T>> = Signal::new(vec![]);
let selected = Signal::new(false);
create_effect(
cloned!((input, visible, item_list, selected, selected_item, remote_list) => move || {
};
let item_list: &Signal<Vec<T>> = create_signal(cx, vec![]);
let selected = create_signal(cx, false);
create_effect(cx, move || {
// Early return if:
// - The input is empty, there is nothing to search for nor to show
// - We just selected an item
@ -48,13 +43,16 @@ where
return;
}
selected_item.set(None);
if G::IS_BROWSER {
perseus::spawn_local(
cloned!((input, visible, item_list, remote_list) => async move {
let res = reqwasm::http::Request::get(
&format!( "{}/{}?limit={}", remote_list.get(), input.get(), 5)
)
props.selected_item.set(None);
#[cfg(client)]
spawn_local_scoped(cx, async move {
let res = reqwasm::http::Request::get(&format!(
"{}/{}?limit={}",
props.remote_list.get(),
input.get(),
5
))
.send()
.await
.unwrap()
@ -64,39 +62,34 @@ where
visible.set(!res.is_empty());
item_list.set(res);
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) {}
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" })) {
ul {
Indexed(IndexedProps {
iterable: item_list.handle(),
template: move |x| {
view! {
Indexed(
iterable=item_list,
view= move |cx, x| {
let item = x.clone();
view! {cx,
li (
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_item.set(Some(x.clone()));
input2.set(x.clone().to_select_list_item());
visible2.set(false);
}),
props.selected_item.set(Some(item.clone()));
input.set(item.to_select_list_item());
visible.set(false);
},
)
{
(x.to_select_list_item())
}
}
},
})
)
}
}
}

@ -1,29 +1,23 @@
use sycamore::prelude::*;
#[derive(Clone)]
pub struct BaseButtonStateRx {
pub label: ReadSignal<String>,
pub disabled: ReadSignal<bool>,
pub clicked: Signal<bool>,
#[derive(Prop)]
pub struct BaseButtonStateRx<'a> {
pub label: &'a ReadSignal<String>,
pub disabled: &'a ReadSignal<bool>,
pub clicked: &'a Signal<bool>,
}
#[component(BaseButton<G>)]
pub fn create_component(
BaseButtonStateRx {
label,
disabled,
clicked,
}: BaseButtonStateRx,
) -> View<G> {
let click_event = cloned!((clicked) => move |_| { clicked.set(true) });
#[component]
pub fn BaseButton<'a, G: Html>(cx: Scope<'a>, props: BaseButtonStateRx<'a>) -> View<G> {
let click_event = |_| props.clicked.set(true);
view! {
view! { cx,
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 ",
disabled=*disabled.get(),
disabled=*props.disabled.get(),
on:click=click_event,
) {
(label.get())
(props.label.get())
}
}
}

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

@ -1,8 +1,8 @@
use sycamore::prelude::*;
#[component(Loading<G>)]
pub fn component() -> View<G> {
view! {
#[component]
pub fn Loading<G: Html>(cx: Scope) -> View<G> {
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",
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"){

@ -1,5 +1,6 @@
use std::rc::Rc;
use perseus::prelude::*;
use serde::Deserialize;
use sycamore::prelude::*;
@ -11,7 +12,7 @@ use crate::{
},
};
#[derive(Clone)]
#[derive(Prop)]
pub struct PaginatedTableStateRx<M, F, C>
where
M: 'static,
@ -33,77 +34,79 @@ where
}
}
#[component(PaginatedTable<G>)]
pub fn component<M, F, C>(state: PaginatedTableStateRx<M, F, C>) -> View<G>
#[component]
pub fn PaginatedTable<'a, G, M, F, C>(
cx: Scope<'a>,
props: PaginatedTableStateRx<M, F, C>,
) -> View<G>
where
G: Html,
M: 'static + Clone,
PaginatedResponse<M>: IntoTableData<G>,
for<'de> M: Deserialize<'de>,
C: Fn(Option<String>, i64, i64) -> F + '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 {
headers_view: Signal::new(vec![]),
data_view: Signal::new(vec![vec![]]),
headers_view: create_signal(cx, vec![]),
data_view: create_signal(cx, vec![vec![]]),
};
let table_prop2 = table_prop.clone();
let page: Signal<i64> = Signal::new(0);
let n_page: Signal<i64> = Signal::new(1);
let n_rows: Signal<i64> = Signal::new(0);
let page = create_signal(cx, 0);
let n_page = create_signal(cx, 1);
let n_rows = create_signal(cx, 0);
let page_up = cloned!((page, paginated_data, n_page) => move |_| {
n_page.set((*paginated_data.get()).as_ref().map_or(0, |t| t.num_pages));
let page_up = move |_| {
n_page.set(
(*paginated_data.get())
.clone()
.map_or(0, |t: PaginatedResponse<M>| t.num_pages),
);
if *page.get() + 1 < *n_page.get() {
page.set((*page.get()).min(*n_page.get() - 1) + 1)
}
});
let page_down = cloned!((page) => move |_| {
};
let page_down = |_| {
if *page.get() > 0 {
page.set((*page.get()-1).max(0));
page.set((*page.get() - 1).max(0));
}
});
};
let page_size_string = create_signal(cx, "20".to_string());
let page_size_string = Signal::new("20".to_string());
let page_size_string2 = page_size_string.clone();
let state_rc = Rc::new(state);
create_effect(
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_size_s = page_size_string.get();
let page_size = page_size_s.parse().unwrap_or(20);
if G::IS_BROWSER {
perseus::spawn_local(
cloned!((table_prop2, page, paginated_data, n_page, n_rows, state_rc) => async move {
let res = state_rc.get_data(page, page_size).await.unwrap();
#[cfg(client)]
spawn_local_scoped(cx, async move {
let res = props_sig.get().get_data(page, page_size).await.unwrap();
paginated_data.set(Some(res.clone()));
n_rows.set(res.count);
let table_content = res.into_table_data();
table_prop2.data_view.set(table_content.data_view);
table_prop2.headers_view.set(table_content.headers_view);
let table_content = res.into_table_data(cx);
table_prop.data_view.set(table_content.data_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));
}),
);
}
}),
);
});
});
view! {
(cloned!((n_rows, page_size_string, page_down, page_up, page, n_page, page_size_string2) =>
view! {
view! { cx,
(if paginated_data.get().is_some() {
view! {cx,
p (class="text-right") { (format!("{} transactions", n_rows.get())) }
div (class="flex flex-row justify-between") {
select (bind:value=page_size_string,
class="p-2 justify-end text-slate-700 dark:text-slate-100 bg-slate-200 dark:bg-slate-800 rounded-md",
id="size-select",
) {
option (value="20") { "20" }
option (value="10") { "10" }
option (value="30", selected=page_size_string2.get().eq(&Rc::new("30".to_string()))) { "30" }
option (value="40") { "40" }
option (value="50") { "50" }
option (value="10", selected=(*page_size_string.get()).eq("10")) { "10" }
option (value="20", selected=(*page_size_string.get()).eq("20")) { "20" }
option (value="30", selected=(*page_size_string.get()).eq("30")) { "30" }
option (value="40", selected=(*page_size_string.get()).eq("40")) { "40" }
option (value="50", selected=(*page_size_string.get()).eq("50")) { "50" }
}
div (class="flex flex-row p-2 bg-slate-200 dark:bg-slate-800 rounded-md") {
div (id="page_buttons", class="flex flex-row p-2 bg-slate-200 dark:bg-slate-800 rounded-md") {
button (on:click=page_down,class="m-1 hover:font-bold") {
"<<"
}
@ -115,13 +118,10 @@ where
}
}
}
}))
(if paginated_data.get().is_some() {
view! {
BaseTable(table_prop.clone())
BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view)
}
} else {
view! {
view! {cx,
div (class="flex flex-row justify-center") {
Loading()
}

@ -1,8 +1,8 @@
use sycamore::prelude::*;
#[component(TheHeader<G>)]
pub fn the_header(_: ()) -> View<G> {
view! {
#[component]
pub fn the_header<G: Html>(cx: Scope) -> View<G> {
view! { cx,
"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") {
// nav () {

@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize};
use std::env;
// use std::env;
#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
pub api_url: String,
}
#[cfg(engine)]
impl Config {
pub fn new() -> Self {
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 {
fn default() -> Self {
Config::new()

@ -1,17 +1,62 @@
use perseus::{ErrorPages, Html};
use sycamore::view;
use perseus::errors::ClientError;
use perseus::prelude::*;
use sycamore::prelude::*;
pub fn get_error_pages<G: Html>() -> ErrorPages<G> {
let mut error_pages = ErrorPages::new(|url, status, err, _| {
view! {
p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) }
pub fn get_error_views<G: Html>() -> ErrorViews<G> {
ErrorViews::new(|cx, err, _err_info, _err_pos| {
match 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 perseus::{state::GlobalStateCreator, RenderFnResult};
use perseus::state::GlobalStateCreator;
pub fn get_global_state_creator() -> GlobalStateCreator {
GlobalStateCreator::new().build_state_fn(get_build_state)
}
#[perseus::make_rx(AppStateRx)]
#[derive(Serialize, Deserialize, ReactiveState)]
#[rx(alias = "AppStateRx")]
pub struct AppState {
pub dark_mode: bool,
pub config: Config,
}
#[perseus::autoserde(global_build_state)]
pub async fn get_build_state() -> RenderFnResult<AppState> {
#[engine_only_fn]
pub async fn get_build_state(_locale: String) -> AppState {
use crate::env::Config;
let config = Config::new();
Ok(AppState {
AppState {
config,
dark_mode: true,
})
}
}

@ -1,4 +1,4 @@
use perseus::{Html, PerseusApp, PerseusRoot};
use perseus::prelude::*;
use sycamore::view;
mod api;
@ -8,14 +8,14 @@ pub mod error_pages;
pub mod global_state;
pub mod templates;
#[perseus::main]
#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new()
.template(crate::templates::index::get_template)
.template(crate::templates::index::get_template())
.global_state_creator(crate::global_state::get_global_state_creator())
.error_pages(crate::error_pages::get_error_pages)
.index_view(|| {
view! {
.error_views(crate::error_pages::get_error_views())
.index_view(|cx| {
view! {cx,
head() {
title { "Fast Insiders" }
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 crate::{
api::{
routes::transaction::get_transactions,
types::{company::Company, transaction::TransactionCompany},
},
api::routes::transaction::get_transactions,
api::types::{company::Company, transaction::TransactionCompany},
components::{
base_async_select::{AsyncSelectRx, BaseAsyncSelect},
base_button::{BaseButton, BaseButtonStateRx},
@ -14,68 +13,69 @@ use crate::{
global_state::AppStateRx,
};
#[perseus::make_rx(IndexPageStateRx)]
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "IndexPageStateRx")]
pub struct IndexPageState {
pub company_slug: String,
}
#[perseus::template_rx]
pub fn index_page(
IndexPageStateRx { company_slug }: IndexPageStateRx,
global_state: AppStateRx,
) -> View<G> {
let dark_mode = global_state.dark_mode;
#[auto_scope]
fn index_page<G: Html>(cx: Scope, state: &IndexPageStateRx) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
let dark_mode = &global_state.dark_mode;
let dark_mode_2 = 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 {
label: Signal::new("Filters".to_string()).handle(),
disabled: Signal::new(false).handle(),
clicked: Signal::new(false),
label: create_signal(cx, "Filters".to_string()),
disabled: create_signal(cx, false),
clicked: create_signal(cx, false),
};
create_effect(cloned!((filter_expand, expand) => move || {
create_effect(cx, move || {
if *filter_expand.clicked.get() {
filter_expand.clicked.set(false);
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, _, _> =
PaginatedTableStateRx {
route: get_transactions,
filter: if (*company_slug.get()).is_empty() {
filter: if (*state.company_slug.get()).is_empty() {
None
} else {
Some((*company_slug.get()).clone())
Some((*state.company_slug.get()).clone())
},
};
let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx {
remote_list: Signal::new(format!("{}company/", global_state.config.get().api_url)).handle(),
selected_item: Signal::new(None),
remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")),
selected_item: create_signal(cx, 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),
label: create_signal(cx, "Search".to_string()),
disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()),
clicked: create_signal(cx, false),
};
create_effect(cloned!((search_button) => move || {
create_effect(cx, || {
if *search_button.clicked.get() {
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 { "" }) {
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") {
@ -101,7 +101,7 @@ pub fn index_page(
}
}
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 {}",
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> {
Template::new("index")
.build_paths_fn(get_build_paths)
Template::build("index")
.head(head)
.build_state_fn(get_build_state)
.template(index_page)
.build_paths_fn(get_build_paths)
.incremental_generation()
.head(head)
.view_with_state(index_page)
.build()
}
#[perseus::head]
pub fn head(_props: IndexPageState) -> View<SsrNode> {
view! {
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders" }
}
}
#[perseus::autoserde(build_state)]
pub async fn get_build_state(
path: String,
_locale: String,
) -> RenderFnResultWithCause<IndexPageState> {
let company_slug: String = path.clone().drain("index".len()..).collect();
#[engine_only_fn]
async fn get_build_state(
StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
) -> Result<IndexPageState, BlamedError<std::io::Error>> {
let company_slug: String = path
.split("transactions")
.nth(1)
.unwrap_or(&("/".to_owned() + &path))
.to_string();
Ok(IndexPageState { company_slug })
}
pub async fn get_build_paths() -> RenderFnResult<Vec<String>> {
Ok(vec!["".to_string()])
#[engine_only_fn]
async fn get_build_paths() -> BuildPaths {
BuildPaths {
paths: vec!["".to_string()],
extra: ().into(),
}
}

@ -1,13 +1,10 @@
use fantoccini::{
actions::{InputSource, KeyAction, KeyActions},
error::CmdError,
Client, Locator,
};
use fantoccini::{Client, Locator};
use perseus::wait_for_checkpoint;
use std::time::Duration;
// This is is an E2E test with the following steps:
// - Go to the index page
// - Try all page sizes and verify that they are respected
// - Try to go to page 2
// - Use the async select component to find any company
// - Click on this company and verify that we arrived
// - Come back to the root using the top left link
@ -17,9 +14,7 @@ use std::time::Duration;
// Run like this:
// - Run any headless browser drive like geckodriver
// - Run perseus test
// - The backend should be available with some data
// - run PERSEUS_RUN_WASM_TESTS=true cargo test -- --test-threads 1
// (or use make test-client-cargo)
// - The backend should be available with at least 11 saved transactions
#[perseus::test]
async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
c.goto("http://localhost:8080").await?;
@ -27,12 +22,61 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
let url = c.current_url().await?;
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
let title = c.find(Locator::Css("title")).await?.html(false).await?;
assert!(title.contains("Fast Insiders"));
wait_for_checkpoint!("page_interactive", 0, c);
// Verify that the default table size is 20
let page_size_select = c.find(Locator::Css("#size-select")).await?;
let default_page_size = page_size_select
.prop("value")
.await?
.expect("The value prop must be set");
assert_eq!(default_page_size.parse::<i32>().unwrap(), 20);
// Test all page sizes
let page_sizes = [20, 30, 40, 50, 10].iter();
for page_size in page_sizes {
let page_size_select = c.find(Locator::Css("#size-select")).await?;
page_size_select
.select_by_value(&page_size.to_string())
.await?;
let table_rows = c.find_all(Locator::Css("table tr")).await?.iter().count();
let page_size_select = c.find(Locator::Css("#size-select")).await?;
let browser_page_size = page_size_select
.prop("value")
.await?
.expect("The value prop must be set");
assert_eq!(browser_page_size.parse::<usize>().unwrap(), *page_size);
assert_eq!(table_rows, page_size + 1); // The header also counts as a table row
}
// Verify that we can change pages
let page_buttons = c.find_all(Locator::Css("#page_buttons button")).await?;
assert_eq!(page_buttons[0].text().await?, "<<");
assert_eq!(page_buttons[1].text().await?, ">>");
let page_numbers = c
.find(Locator::Css("#page_buttons div"))
.await?
.text()
.await?
.split("/")
.map(|x| x.parse().unwrap())
.collect::<Vec<usize>>();
assert_eq!(page_numbers[0], 1);
page_buttons[1].click().await?;
let new_page_numbers = c
.find(Locator::Css("#page_buttons div"))
.await?
.text()
.await?
.split("/")
.map(|x| x.parse().unwrap())
.collect::<Vec<usize>>();
assert_eq!(new_page_numbers[0], page_numbers[0] + 1);
// let table = c.find(Locator::Css("table")).await?.html(true).await?;
let filter_button = c.find(Locator::Css("#main button")).await?;
filter_button.click().await?;
@ -50,10 +94,11 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
search_button.click().await?;
let page = c.current_url().await?;
assert!(
page.as_ref().starts_with("http://localhost:8080/index/"),
page.as_ref().starts_with("http://localhost:8080/"),
"Unexpected target url reached: {}",
page
);
wait_for_checkpoint!("page_interactive", 1, c);
for a in c.find_all(Locator::Css("table a")).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);
@ -70,7 +115,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!(
page.as_ref().starts_with("http://localhost:8080/index/"),
page.as_ref().starts_with("http://localhost:8080/"),
"Unexpected target url reached: {}",
page
);

@ -11,6 +11,8 @@ services:
restart: always
ports:
- 8080:8080
environment:
- PERSEUS_HOST=0.0.0.0
server:
container_name: fast-insiders-server
image: fast-insiders-server:latest

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

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

Loading…
Cancel
Save