proper index page

pull/26/head
Miroito 3 years ago
parent 3976e8ff9b
commit 34ea39bfe0

73
Cargo.lock generated

@ -392,6 +392,17 @@ version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf"
[[package]]
name = "bigdecimal"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aaf33151a6429fe9211d1b276eafdf70cdff28b071e76c0b0e1503221ea3744"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "binascii"
version = "0.1.4"
@ -564,14 +575,11 @@ version = "3.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"clap_lex",
"indexmap",
"once_cell",
"strsim",
"termcolor",
"textwrap",
]
@ -713,21 +721,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484"
[[package]]
name = "crc32fast"
version = "1.3.2"
@ -3300,15 +3293,15 @@ dependencies = [
[[package]]
name = "sea-orm"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88694d01b528a94f90ad87f8d2f546d060d070eee180315c67d158cb69476034"
checksum = "e7a0e3ec90718d849c73b167df7a476672b64c7ee5f3c582179069e63b2451e1"
dependencies = [
"async-stream",
"async-trait",
"bigdecimal",
"chrono",
"futures",
"futures-util",
"log 0.4.17",
"ouroboros",
"rust_decimal",
@ -3328,9 +3321,9 @@ dependencies = [
[[package]]
name = "sea-orm-cli"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ebe1f820fe8949cf6a57272ba9ebd0be766e47c9b85c04b3cabea40ab9459b3"
checksum = "992bc003ed84e736daa19d1b562bd80fa2de09d7bca70cb1745adec3f3b54064"
dependencies = [
"chrono",
"clap",
@ -3344,9 +3337,9 @@ dependencies = [
[[package]]
name = "sea-orm-macros"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216195de9c6b2474fd0efab486173dccd0eff21f28cc54aa4c0205d52fb3af0"
checksum = "5d89f7d4d2533c178e08a9e1990619c391e9ca7b402851d02a605938b15e03d9"
dependencies = [
"bae",
"heck 0.3.3",
@ -3357,13 +3350,14 @@ dependencies = [
[[package]]
name = "sea-orm-migration"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ed3cdfa669e4c385922f902b9a58e0c2128782a4d0fe79c6c34f3b927565e5b"
checksum = "355b1e2763e73d36de6f4539b04fc5d01b232e5c97785e0d08c4becbc2accad3"
dependencies = [
"async-trait",
"clap",
"dotenvy",
"futures",
"sea-orm",
"sea-orm-cli",
"sea-schema",
@ -3393,10 +3387,11 @@ dependencies = [
[[package]]
name = "sea-query"
version = "0.27.2"
version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f0fc4d8e44e1d51c739a68d336252a18bc59553778075d5e32649be6ec92ed"
checksum = "d2fbe015dbdaa7d8829d71c1e14fb6289e928ac256b93dfda543c85cd89d6f03"
dependencies = [
"bigdecimal",
"chrono",
"rust_decimal",
"sea-query-derive",
@ -3407,10 +3402,11 @@ dependencies = [
[[package]]
name = "sea-query-binder"
version = "0.2.2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c2585b89c985cfacfe0ec9fc9e7bb055b776c1a2581c4e3c6185af2b8bf8865"
checksum = "03548c63aec07afd4fd190923e0160d2f2fc92def27470b54154cf232da6203b"
dependencies = [
"bigdecimal",
"chrono",
"rust_decimal",
"sea-query",
@ -3422,11 +3418,11 @@ dependencies = [
[[package]]
name = "sea-query-derive"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34cdc022b4f606353fe5dc85b09713a04e433323b70163e81513b141c6ae6eb5"
checksum = "63f62030c60f3a691f5fe251713b4e220b306e50a71e1d6f9cce1f24bb781978"
dependencies = [
"heck 0.3.3",
"heck 0.4.1",
"proc-macro2 1.0.51",
"quote 1.0.23",
"syn 1.0.108",
@ -3435,9 +3431,9 @@ dependencies = [
[[package]]
name = "sea-schema"
version = "0.10.3"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d5fda574d980e9352b6c7abd6fc75697436fe0078cac2b548559b52643ad3b"
checksum = "eeb2940bb5a10bc6cd05b450ce6cd3993e27fddd7eface2becb97fc5af3a040e"
dependencies = [
"futures",
"sea-query",
@ -3600,7 +3596,6 @@ dependencies = [
"rocket_contrib",
"sea-orm",
"sea-orm-rocket",
"sea-query",
"serde",
"serde_json",
"slug",
@ -3757,11 +3752,11 @@ checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105"
dependencies = [
"ahash 0.7.6",
"atoi",
"bigdecimal",
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"digest",
"dotenvy",
@ -3817,7 +3812,6 @@ dependencies = [
"proc-macro2 1.0.51",
"quote 1.0.23",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-rt",
"syn 1.0.108",
@ -4608,7 +4602,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
dependencies = [
"getrandom",
"serde",
]

@ -1,4 +1,7 @@
use crate::api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany};
use crate::api::types::{
paginated_response::PaginatedResponse,
transaction::{TransactionCompany, TransactionsAggregated},
};
pub async fn get_transactions(
company_slug: Option<String>,
@ -7,6 +10,7 @@ pub async fn get_transactions(
) -> Result<PaginatedResponse<TransactionCompany>, ()> {
use crate::env::Config;
// TODO: Remove build-time environment variable
let api_url = Config::new().api_url;
let route = &format!(
"{}transaction?{}&page={}&size={}",
@ -16,7 +20,6 @@ pub async fn get_transactions(
size,
);
// TODO: Remove build-time environment variable
#[cfg(client)]
let res = reqwasm::http::Request::get(route)
.send()
@ -36,3 +39,40 @@ pub async fn get_transactions(
return Ok(res);
}
pub async fn get_aggregated_transactions(
hours: Option<String>,
page: i64,
size: i64,
) -> Result<PaginatedResponse<TransactionsAggregated>, ()> {
use crate::env::Config;
// TODO: Remove build-time environment variable
let api_url = Config::new().api_url;
let route = &format!(
"{}transaction/aggregated?{}&page={}&size={}",
api_url,
hours.map_or("".to_string(), |c| format!("hours={}", c)),
page,
size,
);
#[cfg(client)]
let res = reqwasm::http::Request::get(route)
.send()
.await
.map_err(|_| ())?
.json::<PaginatedResponse<TransactionsAggregated>>()
.await
.map_err(|_| ())?;
#[cfg(engine)]
let res = reqwest::get(route)
.await
.map_err(|_| ())?
.json::<PaginatedResponse<TransactionsAggregated>>()
.await
.map_err(|_| ())?;
return Ok(res);
}

@ -3,7 +3,7 @@ use sycamore::prelude::*;
use crate::components::base_table::TableContent;
use super::transaction::TransactionCompany;
use super::transaction::{TransactionCompany, TransactionsAggregated};
pub trait IntoTableData<G>
where
@ -43,32 +43,22 @@ where
.into_iter()
.map(|t| {
let mut res = vec![];
let t1 = t.clone();
res.push(view! {cx,
a (href=format!("{}", t1.company.slug),
a (href=format!("transactions/{}", t.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())
(t.company.name.to_owned())
}
});
let t1 = t.clone();
res.push(view! {cx, (t1.date_published.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.date_executed.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.person.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.nature.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.isin.to_owned().unwrap_or_else(|| "-".to_string())) });
let t1 = t.clone();
res.push(view! {cx, (t1.instrument.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.exchange.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.volume.to_owned()) });
let t1 = t.clone();
res.push(view! {cx, (t1.unit_price.to_owned()) });
res.push(view! {cx, (t.date_published.to_owned()) });
res.push(view! {cx, (t.date_executed.to_owned()) });
res.push(view! {cx, (t.person.to_owned()) });
res.push(view! {cx, (t.nature.to_owned()) });
res.push(view! {cx, (t.isin.to_owned().unwrap_or_else(|| "-".to_string())) });
res.push(view! {cx, (t.instrument.to_owned()) });
res.push(view! {cx, (t.exchange.to_owned()) });
res.push(view! {cx, (t.volume.to_owned()) });
res.push(view! {cx, (t.unit_price.to_owned()) });
res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) });
res
@ -81,3 +71,38 @@ where
}
}
}
impl<G> IntoTableData<G> for PaginatedResponse<TransactionsAggregated>
where
G: GenericNode,
{
fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G> {
let headers_view = vec![
view! {cx, "Company" },
view! {cx, "Transactions" },
];
let data_view: Vec<Vec<View<G>>> = self
.list
.into_iter()
.map(|t| {
let mut res = vec![];
res.push(view! {cx,
a (href=format!("transactions/{}", t.slug),
class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 dark:hover:text-indigo-600 hover:underline",
) {
(t.name.to_owned())
}
});
res.push(view! {cx, (t.transaction_count) });
res
})
.collect();
TableContent {
headers_view,
data_view,
}
}
}

@ -36,3 +36,11 @@ pub struct TransactionCompany {
pub created_at_utc: NaiveDateTime,
pub company: Company,
}
#[derive(Deserialize, Clone)]
pub struct TransactionsAggregated {
pub id: i32,
pub name: String,
pub slug: String,
pub transaction_count: i32,
}

@ -0,0 +1,21 @@
use sycamore::prelude::*;
#[derive(Prop)]
pub struct MainProps<'a, G: Html> {
children: Children<'a, G>,
useless_prop: u8, // Without another prop, the view doesn't render with children
}
/// This component is used to contain the main contents of pages
#[component]
pub fn MainContentContainer<'a, G: Html>(cx: Scope<'a>, props: MainProps<'a, G>) -> View<G> {
let children = props.children.call(cx);
view! {cx,
div (id="main", class="flex flex-col items-center justify-center ") {
div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") {
(children)
}
}
}
}

@ -2,5 +2,6 @@ pub mod base_async_select;
pub mod base_button;
pub mod base_table;
pub mod loading;
pub mod main_content_container;
pub mod paginated_data_table;
pub mod the_header;

@ -19,6 +19,7 @@ where
C: Fn(Option<String>, i64, i64) -> F,
F: std::future::Future<Output = Result<PaginatedResponse<M>, ()>>,
{
pub record_label: String,
pub route: C,
pub filter: Option<String>,
}
@ -34,6 +35,7 @@ where
}
}
/// This is a generic component that will display a paginated table given a function of the generic signature C and a filter represented by an Option<String>
#[component]
pub fn PaginatedTable<'a, G, M, F, C>(
cx: Scope<'a>,
@ -74,6 +76,10 @@ where
let page_size_string = create_signal(cx, "20".to_string());
create_effect(cx, move || {
page_size_string.track();
page.set(0);
});
let props_sig = create_signal(cx, props);
create_effect(cx, move || {
let page = *page.get();
@ -93,8 +99,16 @@ where
view! { cx,
(if paginated_data.get().is_some() {
if *n_rows.get() == 0 {
view!{cx,
p (class="text-right") { (format!("{} transactions", n_rows.get())) }
div (class="bg-slate-200 dark:bg-slate-800 text-center rounded-md") {
(format!("No {}", props_sig.get().record_label))
}
}
}
else {
view! {cx,
p (class="text-right") { (format!("{} {}", n_rows.get(), props_sig.get().record_label)) }
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",
@ -120,6 +134,7 @@ where
}
BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view)
}
}
} else {
view! {cx,
div (class="flex flex-row justify-center") {

@ -1,18 +1,48 @@
use perseus::prelude::*;
use sycamore::prelude::*;
use crate::global_state::AppStateRx;
#[component]
pub fn the_header<G: Html>(cx: Scope) -> View<G> {
pub fn TheHeader<'a, G: Html>(cx: Scope<'a>) -> View<G> {
// This is ugly and is only caused by the get_global_state function panicking when running on the server at build time
let global_state_sig: &Signal<Option<&AppStateRx>> = create_signal(cx, None);
#[cfg(client)]
global_state_sig.set(Some(
Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx),
));
let dark_mode = create_signal(cx, true);
create_effect(cx, move || {
if let Some(gstate) = (*global_state_sig.get()).clone() {
dark_mode.set(*gstate.dark_mode.get());
}
});
let toggle_dark_mode = move |_| {
if let Some(gstate) = *global_state_sig.get() {
gstate.dark_mode.set(!*dark_mode.get());
}
};
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 () {
// div (class="fixed flex justify-between") {
// div (class="font-mono mr-10") { "Fast Insiders" }
// div (class="flex justify-end") {
// button (on:click=toggle_dark_mode, class="mx-1 p-1 bg-pink-200 dark:bg-pink-600 rounded-full") { "toggle dark mode" }
// }
// }
// }
// }
header (class="shadow-md h-11 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") {
div (class="flex") {
div (class="flex-none mr-12") {
a (href="/", class="hover:underline") {
"Fast Insiders"
}
}
div (class="grow text-left") {
a (id="header-all-transactions", href="/transactions", class="hover:underline") {
"All transactions"
}
}
div (class="flex-none") {
button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full")
{ "Toggle dark mode" }
}
}
}
}
}

@ -12,6 +12,7 @@ pub mod templates;
pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new()
.template(crate::templates::index::get_template())
.template(crate::templates::transactions::get_template())
.global_state_creator(crate::global_state::get_global_state_creator())
.error_views(crate::error_pages::get_error_views())
.index_view(|cx| {

@ -3,113 +3,42 @@ use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use crate::{
api::routes::transaction::get_transactions,
api::types::{company::Company, transaction::TransactionCompany},
api::{
routes::transaction::get_aggregated_transactions,
types::transaction::TransactionsAggregated,
},
components::{
base_async_select::{AsyncSelectRx, BaseAsyncSelect},
base_button::{BaseButton, BaseButtonStateRx},
main_content_container::MainContentContainer,
paginated_data_table::{PaginatedTable, PaginatedTableStateRx},
the_header::TheHeader,
},
global_state::AppStateRx,
};
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "IndexPageStateRx")]
pub struct IndexPageState {
pub company_slug: Option<String>,
}
#[auto_scope]
fn index_page<G: Html>(cx: Scope, state: &IndexPageStateRx) -> View<G> {
fn index_page<G: Html>(cx: Scope) -> 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 = create_signal(cx, false);
let filter_expand = BaseButtonStateRx {
label: create_signal(cx, "Filters".to_string()),
disabled: create_signal(cx, false),
clicked: create_signal(cx, false),
};
create_effect(cx, move || {
if *filter_expand.clicked.get() {
filter_expand.clicked.set(false);
expand.set(!*expand.get());
}
});
let toggle_dark_mode = move |_| dark_mode_2.set(!*dark_mode.get());
let paginated_table_state: PaginatedTableStateRx<TransactionCompany, _, _> =
let table_transactions_24hours: PaginatedTableStateRx<TransactionsAggregated, _, _> =
PaginatedTableStateRx {
route: get_transactions,
filter: (*state.company_slug.get()).clone(),
};
let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx {
remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")),
selected_item: create_signal(cx, None),
record_label: "companies".to_owned(),
route: get_aggregated_transactions,
filter: Some("24".to_owned()),
};
let search_button = BaseButtonStateRx {
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(cx, || {
if *search_button.clicked.get() {
search_button.clicked.set(false);
navigate(&format!(
"/{}",
(*async_select_prop.selected_item.get())
.clone()
.map_or("".to_string(), |c| c.slug)
));
}
});
view! {cx,
main (class=if *dark_mode_3.get() { "dark" } else { "" }) {
main (class=if *global_state.dark_mode.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") {
div (class="flex flex-row justify-between") {
div (class="mr-10 align-middle") {
a (href="/", class="hover:underline") {
"Fast Insiders"
TheHeader()
MainContentContainer(useless_prop=1) {
div(class="flex flex-wrap gap-4 justify-around") {
div () {
h1 (class="mb-1 text-center") {
"Companies with transactions published"
br () {}
"in the last 24 hours"
}
PaginatedTable(table_transactions_24hours)
}
div (class="align-middle") {
button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full")
{ "Toggle dark mode" }
}
}
}
div (id="main", class="flex flex-col items-center justify-center ") {
div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") {
a (class="hover:underline", href="/") {
h1 (
class="text-center text-lg"
) {
"Insider Transactions published by the AMF"
}
}
BaseButton(filter_expand)
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" },
)
)
{
div (class="w-80") {
p () {"Search for a company:"}
BaseAsyncSelect(async_select_prop)
BaseButton(search_button)
}
}
PaginatedTable(paginated_table_state)
}
}
}
@ -118,13 +47,7 @@ fn index_page<G: Html>(cx: Scope, state: &IndexPageStateRx) -> View<G> {
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("index")
.head(head)
.build_state_fn(get_build_state)
.build_paths_fn(get_build_paths)
.incremental_generation()
.view_with_state(index_page)
.build()
Template::build("index").head(head).view(index_page).build()
}
#[engine_only_fn]
@ -133,19 +56,3 @@ fn head(cx: Scope) -> View<SsrNode> {
title { "Fast Insiders" }
}
}
#[engine_only_fn]
async fn get_build_state(
StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
) -> Result<IndexPageState, BlamedError<std::io::Error>> {
let company_slug = if path.is_empty() { None } else { Some(path) };
Ok(IndexPageState { company_slug })
}
#[engine_only_fn]
async fn get_build_paths() -> BuildPaths {
BuildPaths {
paths: vec!["".to_string()],
extra: ().into(),
}
}

@ -1 +1,2 @@
pub mod index;
pub mod transactions;

@ -0,0 +1,136 @@
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use crate::{
api::routes::transaction::get_transactions,
api::types::{company::Company, transaction::TransactionCompany},
components::{
base_async_select::{AsyncSelectRx, BaseAsyncSelect},
base_button::{BaseButton, BaseButtonStateRx},
main_content_container::MainContentContainer,
paginated_data_table::{PaginatedTable, PaginatedTableStateRx},
the_header::TheHeader,
},
global_state::AppStateRx,
};
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "TransactionsPageStateRx")]
pub struct TransactionsPageState {
pub company_slug: Option<String>,
}
#[auto_scope]
fn transactions_page<G: Html>(cx: Scope, state: &TransactionsPageStateRx) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
let dark_mode = &global_state.dark_mode;
let expand = create_signal(cx, false);
let filter_expand = BaseButtonStateRx {
label: create_signal(cx, "Filters".to_string()),
disabled: create_signal(cx, false),
clicked: create_signal(cx, false),
};
create_effect(cx, move || {
if *filter_expand.clicked.get() {
filter_expand.clicked.set(false);
expand.set(!*expand.get());
}
});
let paginated_table_state: PaginatedTableStateRx<TransactionCompany, _, _> =
PaginatedTableStateRx {
record_label: "transactions".to_owned(),
route: get_transactions,
filter: (*state.company_slug.get()).clone(),
};
let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx {
remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")),
selected_item: create_signal(cx, None),
};
let search_button = BaseButtonStateRx {
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(cx, || {
if *search_button.clicked.get() {
search_button.clicked.set(false);
navigate(&format!(
"/transactions/{}",
(*async_select_prop.selected_item.get())
.clone()
.map_or("".to_string(), |c| c.slug)
));
}
});
view! {cx,
main (class=if *dark_mode.get() { "dark" } else { "" }) {
div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") {
TheHeader()
MainContentContainer(useless_prop=1) {
a (class="hover:underline", href="/transactions") {
h1 (
class="text-center text-lg"
) {
"Insider Transactions published by the AMF"
}
}
BaseButton(filter_expand)
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" },
)
)
{
div (class="w-80") {
p () {"Search for a company:"}
BaseAsyncSelect(async_select_prop)
BaseButton(search_button)
}
}
PaginatedTable(paginated_table_state)
}
}
}
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("transactions")
.head(head)
.build_state_fn(get_build_state)
.build_paths_fn(get_build_paths)
.incremental_generation()
.view_with_state(transactions_page)
.build()
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders" }
}
}
#[engine_only_fn]
async fn get_build_state(
StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
) -> Result<TransactionsPageState, BlamedError<std::io::Error>> {
let company_slug = if path.is_empty() { None } else { Some(path) };
Ok(TransactionsPageState { company_slug })
}
#[engine_only_fn]
async fn get_build_paths() -> BuildPaths {
BuildPaths {
paths: vec!["".to_string()],
extra: ().into(),
}
}

@ -525,10 +525,6 @@ video {
position: static;
}
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
@ -549,14 +545,14 @@ video {
margin: 0.5rem;
}
.m-1 {
margin: 0.25rem;
}
.m-10 {
margin: 2.5rem;
}
.m-1 {
margin: 0.25rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@ -572,12 +568,12 @@ video {
margin-right: 0.25rem;
}
.mb-20 {
margin-bottom: 5rem;
.mr-12 {
margin-right: 3rem;
}
.mr-10 {
margin-right: 2.5rem;
.mb-1 {
margin-bottom: 0.25rem;
}
.flex {
@ -588,12 +584,12 @@ video {
display: table;
}
.h-10 {
height: 2.5rem;
.contents {
display: contents;
}
.h-12 {
height: 3rem;
.h-11 {
height: 2.75rem;
}
.h-40 {
@ -616,6 +612,14 @@ video {
width: 80%;
}
.flex-none {
flex: none;
}
.grow {
flex-grow: 1;
}
.table-auto {
table-layout: auto;
}
@ -636,6 +640,10 @@ video {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-center {
align-items: center;
}
@ -652,6 +660,14 @@ video {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.gap-4 {
gap: 1rem;
}
.rounded-md {
border-radius: 0.375rem;
}
@ -706,29 +722,20 @@ video {
background-color: rgb(226 232 240 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.bg-pink-200 {
.bg-slate-100 {
--tw-bg-opacity: 1;
background-color: rgb(251 207 232 / var(--tw-bg-opacity));
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
}
.bg-slate-100 {
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.p-2 {
padding: 0.5rem;
}
.p-1 {
padding: 0.25rem;
}
.p-3 {
padding: 0.75rem;
}
@ -759,10 +766,6 @@ video {
vertical-align: middle;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-sans {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@ -844,13 +847,13 @@ video {
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.dark .dark\:bg-slate-500\/30 {
background-color: rgb(100 116 139 / 0.3);
.dark .dark\:bg-slate-600 {
--tw-bg-opacity: 1;
background-color: rgb(71 85 105 / var(--tw-bg-opacity));
}
.dark .dark\:bg-pink-600 {
--tw-bg-opacity: 1;
background-color: rgb(219 39 119 / var(--tw-bg-opacity));
.dark .dark\:bg-slate-500\/30 {
background-color: rgb(100 116 139 / 0.3);
}
.dark .dark\:bg-slate-700 {
@ -858,11 +861,6 @@ video {
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
}
.dark .dark\:bg-slate-600 {
--tw-bg-opacity: 1;
background-color: rgb(71 85 105 / var(--tw-bg-opacity));
}
.dark .dark\:text-slate-100 {
--tw-text-opacity: 1;
color: rgb(241 245 249 / var(--tw-text-opacity));
@ -882,9 +880,3 @@ video {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
}
@media (min-width: 640px) {
.sm\:p-2 {
padding: 0.5rem;
}
}

@ -3,6 +3,7 @@ use perseus::wait_for_checkpoint;
// This is is an E2E test with the following steps:
// - Go to the index page
// - Navigate to all transactions
// - 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
@ -27,6 +28,14 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
let title = c.find(Locator::Css("title")).await?.html(false).await?;
assert!(title.contains("Fast Insiders"));
let all_transactions_link = c.find(Locator::Css("#header-all-transactions")).await?;
all_transactions_link.click().await?;
let url = c.current_url().await?;
assert!(url
.as_ref()
.starts_with("http://localhost:8080/transactions"));
wait_for_checkpoint!("page_interactive", 1, 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
@ -94,18 +103,19 @@ 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/"),
page.as_ref()
.starts_with("http://localhost:8080/transactions"),
"Unexpected target url reached: {}",
page
);
wait_for_checkpoint!("page_interactive", 1, c);
wait_for_checkpoint!("page_interactive", 2, 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);
}
let root_link = c.find(Locator::Css("a")).await?;
root_link.click().await?;
let transactions_link = c.find(Locator::Css("a h1")).await?;
transactions_link.click().await?;
let first_company_in_table = c.find(Locator::Css("table a")).await?;
let company_name = first_company_in_table.text().await?;
@ -115,7 +125,8 @@ 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/"),
page.as_ref()
.starts_with("http://localhost:8080/transactions"),
"Unexpected target url reached: {}",
page
);

@ -18,7 +18,14 @@ serve:
cd client && \
perseus serve -w
client-deploy:
check-client:
cd client && \
perseus check -w
check-server:
cargo check
deploy-client:
cd client && \
perseus deploy

@ -15,7 +15,7 @@ envy = { workspace = true }
tokio = { version = "^1.20.1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
sea-orm = { version = "0.10.7", features = [
sea-orm = { version = "0.11.0", features = [
"runtime-tokio-rustls",
"macros",
"sqlx-mysql",
@ -30,5 +30,4 @@ rocket_contrib = "0.4.11"
async-trait = "0.1.61"
sea-orm-rocket = "0.5.2"
thiserror = "1.0.38"
sea-query = "^0.27.1"
slug = "0.1.4"

@ -13,5 +13,5 @@ async-std = { version = "^1", features = ["attributes", "tokio1"] }
chrono.workspace = true
[dependencies.sea-orm-migration]
version = "^0.10.0"
version = "0.11.0"
features = ["sqlx-mysql", "runtime-tokio-rustls"]

@ -1,4 +1,5 @@
pub use sea_orm_migration::prelude::*;
use sea_orm_migration::prelude::*;
pub use sea_orm_migration::MigratorTrait;
mod m20230112_115856_create_company_table;
mod m20230112_160440_create_transaction_table;

@ -1,7 +1,7 @@
use sea_orm::{error::DbErr, FromQueryResult};
use sea_orm::{prelude::*, Order, QueryOrder};
use sea_orm::{
error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, Order, QueryOrder,
};
use sea_orm_rocket::Connection;
use sea_query::expr::SimpleExpr;
use serde::{Deserialize, Serialize};
use super::Db;

@ -54,6 +54,7 @@ async fn start_rocket() -> Result<(), sea_orm_rocket::rocket::Error> {
route::company::get_all,
route::company::get_by_isin,
route::transaction::get_transactions,
route::transaction::get_aggregated_transactions,
route::in_process_transaction::get_all,
route::in_process_transaction::retry_failed_transaction,
route::in_process_transaction::retry_all

@ -1,7 +1,13 @@
use chrono::{Duration, NaiveDate, NaiveDateTime, Utc};
use rocket::http::Status;
use rocket::response::status::Custom;
use sea_orm::{ColumnTrait, Order};
use sea_orm::prelude::*;
use sea_orm::FromQueryResult;
use sea_orm::ItemsAndPagesNumber;
use sea_orm::JoinType;
use sea_orm::Order;
use sea_orm::QueryOrder;
use sea_orm::QuerySelect;
use sea_orm_rocket::rocket::serde::json::Json;
use sea_orm_rocket::Connection;
use serde::{Deserialize, Serialize};
@ -98,3 +104,75 @@ pub async fn get_transactions(
Ok(Json(res))
}
#[get("/transaction/aggregated?<page>&<size>&<hours>")]
pub async fn get_aggregated_transactions(
conn: Connection<'_, Db>,
hours: Option<i64>,
page: Option<u64>,
size: Option<u64>,
) -> Result<Json<PaginatedResponse<TransactionsAggregated>>, Custom<String>> {
let db = conn.into_inner();
let s = size.unwrap_or(20).min(50);
let mut query = model::company::Entity::find()
.select_only()
.join(
JoinType::InnerJoin,
model::company::Relation::Transaction.def(),
)
.column(model::company::Column::Id)
.column(model::company::Column::Name)
.column(model::company::Column::Slug)
.column_as(model::transaction::Column::Id.count(), "transaction_count");
if let Some(h) = hours {
query = query.filter(
model::transaction::Column::CreatedAtUtc.gte(
Utc::now()
.naive_utc()
.checked_sub_signed(Duration::hours(h)),
),
)
}
let pages = query
.group_by(model::company::Column::Name)
.order_by(model::transaction::Column::Id.count(), Order::Desc)
.into_model::<TransactionsAggregated>()
.paginate(db, s);
let ItemsAndPagesNumber {
number_of_items: count,
number_of_pages: num_pages,
} = pages.num_items_and_pages().await.map_err(|e| {
Custom(
Status::InternalServerError,
format!("Database error: {}", e),
)
})?;
let p = page.unwrap_or(0).min(num_pages);
let list = pages.fetch_page(p).await.map_err(|e| {
Custom(
Status::InternalServerError,
format!("Database error: {}", e),
)
})?;
let res = PaginatedResponse {
count,
num_pages,
list,
};
Ok(Json(res))
}
#[derive(Serialize, FromQueryResult, Debug)]
pub struct TransactionsAggregated {
id: i32,
name: String,
slug: String,
transaction_count: i32,
}

Loading…
Cancel
Save