feat followed companies page

users
Miroito 2 years ago
parent 38b3797304
commit 13687748e0

@ -2,6 +2,7 @@ use crate::api::{
types::{ types::{
company::Company, company::Company,
paginated_response::PaginatedResponse, paginated_response::PaginatedResponse,
transaction::UserTransaction,
user::{FollowCompany, UserProfile}, user::{FollowCompany, UserProfile},
}, },
FastInsidersApi, FastInsidersApi,
@ -108,6 +109,21 @@ impl FastInsidersApi {
Ok(()) Ok(())
} }
pub async fn unfollow_company(&self, company_id: i32) -> Result<(), ()> {
let route = &format!("{}/auth/unfollow_company", self.url);
#[cfg(client)]
let res = {
let body = FollowCompany { company_id };
let resp = post_auth_route(route, body).await?;
};
#[cfg(engine)]
return Err(());
Ok(())
}
pub async fn get_followed_companies( pub async fn get_followed_companies(
&self, &self,
page: i64, page: i64,
@ -126,7 +142,6 @@ impl FastInsidersApi {
.map_err(|_| ())? .map_err(|_| ())?
}; };
#[cfg(engine)]
#[cfg(engine)] #[cfg(engine)]
let res = reqwest::get(route) let res = reqwest::get(route)
.await .await
@ -137,4 +152,33 @@ impl FastInsidersApi {
Ok(res) Ok(res)
} }
pub async fn get_user_transactions(
&self,
page: i64,
size: i64,
) -> Result<PaginatedResponse<UserTransaction>, ()> {
let route = &format!(
"{}/auth/user_transactions?page={}&size={}",
self.url, page, size
);
#[cfg(client)]
let res = {
let resp = get_auth_route(route).await?;
resp.json::<PaginatedResponse<UserTransaction>>()
.await
.map_err(|_| ())?
};
#[cfg(engine)]
let res = reqwest::get(route)
.await
.map_err(|_| ())?
.json::<PaginatedResponse<UserTransaction>>()
.await
.map_err(|_| ())?;
Ok(res)
}
} }

@ -1,9 +1,10 @@
use perseus::{reactor::Reactor, web_log, prelude::navigate};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sycamore::prelude::*; use sycamore::{prelude::*, futures::spawn_local_scoped, rt::Event};
use crate::components::base_table::TableContent; use crate::{components::base_table::TableContent, global_state::AppStateRx};
use super::{transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions}, company::Company}; use super::{transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions, UserTransaction}, company::Company};
pub trait IntoTableData<G> pub trait IntoTableData<G>
where where
@ -193,13 +194,43 @@ where
impl<G> IntoTableData<G> for PaginatedResponse<Company> impl<G> IntoTableData<G> for PaginatedResponse<Company>
where where
G: GenericNode, G: GenericNode + perseus::prelude::Html,
{ {
fn into_table_data(self, cx: Scope) -> TableContent<G> { fn into_table_data(self, cx: Scope) -> TableContent<G> {
let headers_view = vec![ let headers_view = vec![
view! {cx, "Company" }, view! {cx, "Company" },
view! {cx, "Unfollow" },
]; ];
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
/// This function returns a function that knows wich company id to remove
let unfollow_company = move |company_id: i32| {
#[cfg(client)]
return {
move |e: Event| {
spawn_local_scoped(cx, async move {
match api_scope_ref.unfollow_company(company_id).await {
Ok(()) => {
// TODO find a way to only ask for the table to refresh instead of
// the complete page
navigate("/profile")
}
Err(e) => (),
};
})
}
};
#[cfg(engine)]
return {
move |_| {}
};
};
let data_view: Vec<Vec<View<G>>> = self let data_view: Vec<Vec<View<G>>> = self
.list .list
.into_iter() .into_iter()
@ -212,6 +243,66 @@ where
(t.name.to_owned()) (t.name.to_owned())
} }
}); });
res.push(view!{cx,
svg (class="m-auto cursor-pointer", on:click=unfollow_company(t.id), xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round") {
circle (cx="12", cy="12", r="10") {}
line (x1="8", y1="12", x2="16", y2="12") {}
}
});
res
})
.collect();
TableContent {
headers_view,
data_view,
}
}
}
impl<G> IntoTableData<G> for PaginatedResponse<UserTransaction>
where
G: GenericNode,
{
fn into_table_data(self, cx: Scope) -> TableContent<G> {
let headers_view = vec![
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<G>>> = self
.list
.into_iter()
.map(|t| {
let mut res = vec![];
res.push(view! {cx,
a (href=format!("transactions/{}", t.company_slug),
class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 hover:underline dark:hover:text-indigo-600",
) {
(t.company_name.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 res
}) })

@ -65,3 +65,19 @@ pub struct MajorTransactions {
pub unit_price: f32, pub unit_price: f32,
pub total: f32, pub total: f32,
} }
#[derive(Clone, Deserialize)]
pub struct UserTransaction {
pub company_name: String,
pub company_slug: String,
pub date_published: NaiveDate,
pub date_executed: NaiveDate,
pub person: String,
pub exchange: String,
pub nature: String,
pub isin: Option<String>,
pub instrument: String,
pub volume: i32,
pub unit_price: f32,
pub total: f32,
}

@ -1,2 +1,3 @@
pub mod dark_mode_btn; pub mod dark_mode_btn;
pub mod user_header;
pub mod user_icon; pub mod user_icon;

@ -0,0 +1,40 @@
use lazy_static::lazy_static;
use perseus::prelude::*;
use sycamore::{prelude::*, rt::Event};
use crate::global_state::AppStateRx;
lazy_static! {
pub static ref USER_HEADER: Capsule<PerseusNodeType, ()> = get_capsule();
}
fn user_header<G: Html>(cx: Scope, _props: ()) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
view! { cx,
(if *global_state.logged_in.get() {
view! { cx,
div (class="px-6 text-left border-l border-slate-700 dark:border-slate-300") {
a (id="header-followed-companies", href="/user_transactions", class="hover:underline") {
"Followed companies"
}
}
}
} else {
view!{cx, }
})
div (class="grow") {}
}
}
fn fallback<G: Html>(cx: Scope, _props: ()) -> View<G> {
view! { cx,
}
}
pub fn get_capsule<G: Html>() -> Capsule<G, ()> {
Capsule::build(Template::build("user_header"))
.fallback(fallback)
.view(user_header)
.build()
}

@ -32,6 +32,7 @@ fn user_icon<G: Html>(cx: Scope, _props: ()) -> View<G> {
let logout = move |e: Event| { let logout = move |e: Event| {
wasm_cookies::delete("token"); wasm_cookies::delete("token");
global_state.logged_in.set(false); global_state.logged_in.set(false);
navigate("/")
}; };
#[cfg(engine)] #[cfg(engine)]

@ -16,7 +16,7 @@ where
{ {
pub headers_view: &'a Signal<Vec<View<G>>>, pub headers_view: &'a Signal<Vec<View<G>>>,
pub data_view: &'a Signal<Vec<Vec<View<G>>>>, pub data_view: &'a Signal<Vec<Vec<View<G>>>>,
pub table_class: &'a String, pub table_class: &'a str,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -51,7 +51,7 @@ where
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!{cx, th (class="m-2 p-2 border-slate-500 border-x border-dashed") { (cell) } } view!{cx, th (class="p-2 m-2 border-dashed border-slate-500 border-x") { (cell) } }
} ).collect() } ).collect()
)); ));
@ -64,12 +64,12 @@ where
view! { cx, view! { cx,
table (class=format!("{} table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2", props.table_class)) { table (class=format!("{} table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2", props.table_class)) {
thead { thead {
tr (class="border-b-2 border-slate-500 text-center") { tr (class="text-center border-b-2 border-slate-500") {
Keyed( Keyed(
iterable=headers, iterable=headers,
view=|cx, v| { view=|cx, v| {
view! {cx, view! {cx,
th (class="m-2 p-2") { (v.1) } th (class="p-2 m-2") { (v.1) }
} }
}, },
key=|v| v.0, key=|v| v.0,
@ -81,7 +81,7 @@ where
iterable=data, iterable=data,
view=|cx, t| { view=|cx, t| {
view! {cx, view! {cx,
tr (class="m-2 p-2 border-slate-500 border") { tr (class="p-2 m-2 border border-slate-500") {
(t.1) (t.1)
} }
} }

@ -1,23 +1,26 @@
use perseus::prelude::*; use perseus::prelude::*;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::capsules::{dark_mode_btn::DARK_MODE_BTN, user_icon::USER_ICON}; use crate::capsules::{
dark_mode_btn::DARK_MODE_BTN, user_header::USER_HEADER, user_icon::USER_ICON,
};
#[component] #[component]
pub fn TheHeader<G: Html>(cx: Scope) -> View<G> { pub fn TheHeader<G: Html>(cx: Scope) -> View<G> {
view! { cx, view! { cx,
header (class="p-2 w-full h-11 align-middle bg-gray-100 shadow-md backdrop-blur-lg dark:bg-slate-500/30") { header (class="p-2 w-full h-11 align-middle bg-gray-100 shadow-md backdrop-blur-lg dark:bg-slate-500/30") {
div (class="flex") { div (class="flex") {
div (class="flex-none mr-12") { div (class="flex-none mx-6") {
a (href="/", class="hover:underline") { a (href="/", class="hover:underline") {
"Fast Insiders" "Fast Insiders"
} }
} }
div (class="text-left grow") { div (class="px-6 text-left") {
a (id="header-all-transactions", href="/transactions", class="hover:underline") { a (id="header-all-transactions", href="/transactions", class="hover:underline") {
"All transactions" "All transactions"
} }
} }
(USER_HEADER.widget(cx, "",()))
div (class="flex items-center pl-3 border-l border-slate-700 dark:border-slate-300") { div (class="flex items-center pl-3 border-l border-slate-700 dark:border-slate-300") {
div(class="mx-1") { div(class="mx-1") {
(DARK_MODE_BTN.widget(cx,"",())) (DARK_MODE_BTN.widget(cx,"",()))

@ -17,7 +17,10 @@ pub fn main<G: Html>() -> PerseusApp<G> {
.template(crate::templates::login::get_template()) .template(crate::templates::login::get_template())
.template(crate::templates::profile::get_template()) .template(crate::templates::profile::get_template())
.template(crate::templates::register::get_template()) .template(crate::templates::register::get_template())
.template(crate::templates::user_transactions::get_template())
.capsule_ref(&*crate::capsules::dark_mode_btn::DARK_MODE_BTN) .capsule_ref(&*crate::capsules::dark_mode_btn::DARK_MODE_BTN)
.capsule_ref(&*crate::capsules::user_icon::USER_ICON)
.capsule_ref(&*crate::capsules::user_header::USER_HEADER)
.global_state_creator(crate::global_state::get_global_state_creator()) .global_state_creator(crate::global_state::get_global_state_creator())
.error_views(crate::error_pages::get_error_views()) .error_views(crate::error_pages::get_error_views())
.index_view(|cx| { .index_view(|cx| {

@ -3,3 +3,4 @@ pub mod login;
pub mod profile; pub mod profile;
pub mod register; pub mod register;
pub mod transactions; pub mod transactions;
pub mod user_transactions;

@ -0,0 +1,89 @@
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use crate::{
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>,
}
fn user_transactions_page<'a, G: Html>(cx: Scope) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
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 route_ref = create_ref(cx, move |_, p, s| api_scope_ref.get_user_transactions(p, s));
let paginated_table_state: PaginatedTableStateRx<_, _, _> = PaginatedTableStateRx {
record_label: "transactions".to_owned(),
route: route_ref,
filter: None,
table_class: create_ref(cx, "".to_string()),
refresh: create_signal(cx, true),
};
let dark_mode_class = create_memo(cx, || {
if *global_state.dark_mode.get() {
"dark"
} else {
""
}
});
view! {cx,
main (class=format!("{} flex flex-1", dark_mode_class)) {
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
TheHeader()
MainContentContainer(useless_prop=1) {
a (class="hover:underline", href="/user_transactions") {
h1 (
class="text-lg text-center"
) {
"Latest transactions from your followed companies"
}
}
PaginatedTable(paginated_table_state)
}
}
}
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("user_transactions")
.head(head)
.view(user_transactions_page)
.build()
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders" }
}
}

File diff suppressed because one or more lines are too long

@ -1,5 +1,6 @@
use sea_orm::{ use sea_orm::{
error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, Order, QueryOrder, error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, ItemsAndPagesNumber, Order,
QueryOrder,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -31,9 +32,10 @@ where
} }
let pages = selector.into_model().paginate(db, s); let pages = selector.into_model().paginate(db, s);
let count = pages.num_items().await?; let ItemsAndPagesNumber {
number_of_items: count,
let num_pages = pages.num_pages().await?; number_of_pages: num_pages,
} = pages.num_items_and_pages().await?;
let p = page.unwrap_or(0).min(num_pages); let p = page.unwrap_or(0).min(num_pages);
@ -82,9 +84,10 @@ where
} }
let pages = selector.into_model().paginate(db, s); let pages = selector.into_model().paginate(db, s);
let count = pages.num_items().await?; let ItemsAndPagesNumber {
number_of_items: count,
let num_pages = pages.num_pages().await?; number_of_pages: num_pages,
} = pages.num_items_and_pages().await?;
let p = page.unwrap_or(0).min(num_pages); let p = page.unwrap_or(0).min(num_pages);

@ -47,8 +47,8 @@ use crate::{
crypto::{jwt_numeric_date, JWTSecretManager}, crypto::{jwt_numeric_date, JWTSecretManager},
route::{ route::{
authenticated::{ authenticated::{
follow_company_route, get_followed_companies, get_profile, is_authenticated, follow_company_route, get_followed_companies, get_profile,
unfollow_company_route, get_user_followed_companies_transactions, is_authenticated, unfollow_company_route,
}, },
user, user,
}, },
@ -195,6 +195,10 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/is_authenticated", get(is_authenticated)) .route("/is_authenticated", get(is_authenticated))
.route("/profile", get(get_profile)) .route("/profile", get(get_profile))
.route("/get_followed_companies", get(get_followed_companies)) .route("/get_followed_companies", get(get_followed_companies))
.route(
"/user_transactions",
get(get_user_followed_companies_transactions),
)
.route("/follow_company", post(follow_company_route)) .route("/follow_company", post(follow_company_route))
.route("/unfollow_company", post(unfollow_company_route)) .route("/unfollow_company", post(unfollow_company_route))
.layer(from_fn_with_state(shared_state.clone(), authenticator)) .layer(from_fn_with_state(shared_state.clone(), authenticator))

@ -2,8 +2,10 @@ use axum::{
extract::{Query, State}, extract::{Query, State},
Extension, Json, Extension, Json,
}; };
use chrono::NaiveDate;
use sea_orm::{ use sea_orm::{
ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, sea_query::Expr, ColumnTrait, EntityTrait, FromQueryResult, ItemsAndPagesNumber, JoinType,
ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,7 +14,7 @@ use crate::{
error::AppError, error::AppError,
model::{ model::{
self, self,
prelude::{Company, User}, prelude::{Company, Transaction, User},
}, },
repo::user::{follow_company, unfollow_company}, repo::user::{follow_company, unfollow_company},
AppState, UserJWTClaim, AppState, UserJWTClaim,
@ -103,6 +105,7 @@ pub async fn get_followed_companies(
.await? .await?
.ok_or(AppError::NotFound("User not found".to_string()))?; .ok_or(AppError::NotFound("User not found".to_string()))?;
// Eventually switch these 2 by using cloumn_as to make a count column
let (count_res, companies) = tokio::join!(user.find_related(Company).count(db), async { let (count_res, companies) = tokio::join!(user.find_related(Company).count(db), async {
user.find_related(Company) user.find_related(Company)
.order_by(model::company::Column::Name, sea_orm::Order::Asc) .order_by(model::company::Column::Name, sea_orm::Order::Asc)
@ -122,3 +125,76 @@ pub async fn get_followed_companies(
Ok(Json(res)) Ok(Json(res))
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, FromQueryResult)]
pub struct UserTransaction {
pub company_name: String,
pub company_slug: String,
pub date_published: NaiveDate,
pub date_executed: NaiveDate,
pub person: String,
pub exchange: String,
pub nature: String,
pub isin: Option<String>,
pub instrument: String,
pub volume: i32,
pub unit_price: f32,
pub total: f32,
}
pub async fn get_user_followed_companies_transactions(
Extension(token_data): Extension<UserJWTClaim>,
State(state): State<AppState>,
Query(Pagination { page, size }): Query<Pagination>,
) -> Result<Json<PaginatedResponse<UserTransaction>>, AppError> {
let db = &state.db;
let s = size.unwrap_or(20).min(50);
let query = Transaction::find()
.select_only()
.join(
JoinType::InnerJoin,
model::transaction::Relation::Company.def(),
)
.join(
JoinType::InnerJoin,
model::company::Relation::UserCompany.def(),
)
.column_as(model::company::Column::Name, "company_name")
.column_as(model::company::Column::Slug, "company_slug")
.column(model::transaction::Column::DatePublished)
.column(model::transaction::Column::DateExecuted)
.column(model::transaction::Column::Person)
.column(model::transaction::Column::Exchange)
.column(model::transaction::Column::Nature)
.column(model::transaction::Column::Isin)
.column(model::transaction::Column::Instrument)
.column(model::transaction::Column::Volume)
.column(model::transaction::Column::UnitPrice)
.column_as(
Expr::col(model::transaction::Column::UnitPrice)
.mul(Expr::col(model::transaction::Column::Volume)),
"total",
)
.filter(model::user_company::Column::UserId.eq(token_data.user_id))
.order_by_desc(model::transaction::Column::DatePublished)
.into_model::<UserTransaction>()
.paginate(db, s);
let ItemsAndPagesNumber {
number_of_pages: num_pages,
number_of_items: count,
} = query.num_items_and_pages().await?;
let p = page.unwrap_or(0).min(num_pages);
let list = query.fetch_page(p).await?;
let res = PaginatedResponse {
count,
num_pages,
list,
};
Ok(Json(res))
}

Loading…
Cancel
Save