Display messages, load messages, refactor
This commit is contained in:
parent
c5485705a1
commit
5c29a90523
@ -1 +1,3 @@
|
|||||||
pub fn main() {}
|
pub fn main() {
|
||||||
|
// tui::backend::CrosstermBackend::new(stdout());
|
||||||
|
}
|
||||||
|
@ -103,3 +103,23 @@ nav#sidebar .linkItem > a > .linkText {
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
font-size: 14.7px;
|
font-size: 14.7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.styledTooltip.messages {
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styledTooltip.messages > .messagesList {}
|
||||||
|
|
||||||
|
.styledTooltip.messages > .messagesList > .message {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styledTooltip.messages > .messagesList > .message > .summary {
|
||||||
|
font-family: var(--font-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.styledTooltip.messages > .messagesList > .message > .description {
|
||||||
|
font-family: var(--font-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.styledTooltip.messages > .messagesList > .message > .hyperlink {}
|
||||||
|
@ -109,12 +109,14 @@ impl std::fmt::Display for FieldId {
|
|||||||
UsersFieldId::Email => f.write_str("users-email"),
|
UsersFieldId::Email => f.write_str("users-email"),
|
||||||
UsersFieldId::UserRole => f.write_str("users-userRole"),
|
UsersFieldId::UserRole => f.write_str("users-userRole"),
|
||||||
UsersFieldId::Avatar => f.write_str("users-avatar"),
|
UsersFieldId::Avatar => f.write_str("users-avatar"),
|
||||||
|
UsersFieldId::CurrentProject => f.write_str("users-currentProject"),
|
||||||
},
|
},
|
||||||
FieldId::Profile(sub) => match sub {
|
FieldId::Profile(sub) => match sub {
|
||||||
UsersFieldId::Username => f.write_str("profile-username"),
|
UsersFieldId::Username => f.write_str("profile-username"),
|
||||||
UsersFieldId::Email => f.write_str("profile-email"),
|
UsersFieldId::Email => f.write_str("profile-email"),
|
||||||
UsersFieldId::UserRole => f.write_str("profile-userRole"),
|
UsersFieldId::UserRole => f.write_str("profile-userRole"),
|
||||||
UsersFieldId::Avatar => f.write_str("profile-avatar"),
|
UsersFieldId::Avatar => f.write_str("profile-avatar"),
|
||||||
|
UsersFieldId::CurrentProject => f.write_str("profile-currentProject"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,9 @@ pub enum Msg {
|
|||||||
PageChanged(PageChanged),
|
PageChanged(PageChanged),
|
||||||
ChangePage(model::Page),
|
ChangePage(model::Page),
|
||||||
|
|
||||||
|
UserChanged(Option<User>),
|
||||||
|
ProjectChanged(Option<Project>),
|
||||||
|
|
||||||
StyledSelectChanged(FieldId, StyledSelectChange),
|
StyledSelectChanged(FieldId, StyledSelectChange),
|
||||||
InternalFailure(String),
|
InternalFailure(String),
|
||||||
ToggleTooltip(StyledTooltip),
|
ToggleTooltip(StyledTooltip),
|
||||||
|
@ -550,8 +550,6 @@ fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
|
fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
|
||||||
use crate::shared::styled_button::Variant as ButtonVariant;
|
|
||||||
|
|
||||||
let submit_comment_form = mouse_ev(Ev::Click, move |ev| {
|
let submit_comment_form = mouse_ev(Ev::Click, move |ev| {
|
||||||
ev.stop_propagation();
|
ev.stop_propagation();
|
||||||
Msg::SaveComment
|
Msg::SaveComment
|
||||||
@ -573,13 +571,13 @@ fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
|
|||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let submit = StyledButton::build()
|
let submit = StyledButton::build()
|
||||||
.variant(ButtonVariant::Primary)
|
.primary()
|
||||||
.on_click(submit_comment_form)
|
.on_click(submit_comment_form)
|
||||||
.text("Save")
|
.text("Save")
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
let cancel = StyledButton::build()
|
let cancel = StyledButton::build()
|
||||||
.variant(ButtonVariant::Empty)
|
.empty()
|
||||||
.on_click(close_comment_form)
|
.on_click(close_comment_form)
|
||||||
.text("Cancel")
|
.text("Cancel")
|
||||||
.build()
|
.build()
|
||||||
@ -589,8 +587,6 @@ fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<Node<Msg>> {
|
fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<Node<Msg>> {
|
||||||
use crate::shared::styled_button::Variant as ButtonVariant;
|
|
||||||
|
|
||||||
let show_form = modal.comment_form.creating && modal.comment_form.id == Some(comment.id);
|
let show_form = modal.comment_form.creating && modal.comment_form.id == Some(comment.id);
|
||||||
|
|
||||||
let user = model.users.iter().find(|u| u.id == comment.user_id)?;
|
let user = model.users.iter().find(|u| u.id == comment.user_id)?;
|
||||||
@ -617,7 +613,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
|
|||||||
))
|
))
|
||||||
}))
|
}))
|
||||||
.text("Edit")
|
.text("Edit")
|
||||||
.variant(ButtonVariant::Empty)
|
.empty()
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
@ -625,7 +621,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
|
|||||||
.add_class("deleteButton")
|
.add_class("deleteButton")
|
||||||
.on_click(delete_comment_handler)
|
.on_click(delete_comment_handler)
|
||||||
.text("Delete")
|
.text("Delete")
|
||||||
.variant(ButtonVariant::Empty)
|
.empty()
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
|
@ -99,7 +99,7 @@ pub fn time_tracking_field(
|
|||||||
.map(|n| (*n).to_child())
|
.map(|n| (*n).to_child())
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
.with_state(select_state)
|
.state(select_state)
|
||||||
.options(
|
.options(
|
||||||
fibonacci_values()
|
fibonacci_values()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -402,10 +402,11 @@ pub struct ProfilePage {
|
|||||||
pub name: StyledInputState,
|
pub name: StyledInputState,
|
||||||
pub email: StyledInputState,
|
pub email: StyledInputState,
|
||||||
pub avatar: StyledImageInputState,
|
pub avatar: StyledImageInputState,
|
||||||
|
pub current_project: StyledSelectState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfilePage {
|
impl ProfilePage {
|
||||||
pub fn new(user: &User) -> Self {
|
pub fn new(user: &User, project_ids: Vec<ProjectId>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: StyledInputState::new(
|
name: StyledInputState::new(
|
||||||
FieldId::Profile(UsersFieldId::Username),
|
FieldId::Profile(UsersFieldId::Username),
|
||||||
@ -419,6 +420,10 @@ impl ProfilePage {
|
|||||||
FieldId::Profile(UsersFieldId::Avatar),
|
FieldId::Profile(UsersFieldId::Avatar),
|
||||||
user.avatar_url.as_ref().cloned(),
|
user.avatar_url.as_ref().cloned(),
|
||||||
),
|
),
|
||||||
|
current_project: StyledSelectState::new(
|
||||||
|
FieldId::Profile(UsersFieldId::CurrentProject),
|
||||||
|
project_ids.into_iter().map(|n| n as u32).collect(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -495,17 +500,7 @@ impl Model {
|
|||||||
users: vec![],
|
users: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
issue_statuses: vec![],
|
issue_statuses: vec![],
|
||||||
messages: vec![Message {
|
messages: vec![],
|
||||||
id: 0,
|
|
||||||
receiver_id: 1,
|
|
||||||
sender_id: 2,
|
|
||||||
summary: "You have been invited".to_string(),
|
|
||||||
description: "You have been invited to project A".to_string(),
|
|
||||||
message_type: MessageType::ReceivedInvitation,
|
|
||||||
hyper_link: "/project/1".to_string(),
|
|
||||||
created_at: chrono::NaiveDateTime::from_timestamp(4567890, 123),
|
|
||||||
updated_at: chrono::NaiveDateTime::from_timestamp(1234567, 098),
|
|
||||||
}],
|
|
||||||
user_projects: vec![],
|
user_projects: vec![],
|
||||||
projects: vec![],
|
projects: vec![],
|
||||||
}
|
}
|
||||||
|
@ -1,163 +0,0 @@
|
|||||||
use seed::{prelude::*, *};
|
|
||||||
use web_sys::FormData;
|
|
||||||
|
|
||||||
use jirs_data::*;
|
|
||||||
|
|
||||||
use crate::model::{Model, Page, PageContent, ProfilePage};
|
|
||||||
use crate::shared::styled_button::StyledButton;
|
|
||||||
use crate::shared::styled_field::StyledField;
|
|
||||||
use crate::shared::styled_form::StyledForm;
|
|
||||||
use crate::shared::styled_image_input::StyledImageInput;
|
|
||||||
use crate::shared::styled_input::StyledInput;
|
|
||||||
use crate::shared::{inner_layout, ToNode};
|
|
||||||
use crate::ws::send_ws_msg;
|
|
||||||
use crate::{FieldId, Msg, PageChanged, ProfilePageChange, WebSocketChanged};
|
|
||||||
|
|
||||||
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
|
||||||
match msg {
|
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
|
|
||||||
| Msg::ChangePage(Page::Profile) => {
|
|
||||||
init_load(model, orders);
|
|
||||||
build_page_content(model);
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile_page = match &mut model.page_content {
|
|
||||||
PageContent::Profile(profile_page) => profile_page,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
profile_page.name.update(&msg);
|
|
||||||
profile_page.email.update(&msg);
|
|
||||||
profile_page.avatar.update(&msg);
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Msg::FileInputChanged(FieldId::Profile(UsersFieldId::Avatar), ..) => {
|
|
||||||
let file = match profile_page.avatar.file.as_ref() {
|
|
||||||
Some(f) => f,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let token = match crate::shared::read_auth_token() {
|
|
||||||
Ok(uuid) => uuid,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let fd = FormData::new().unwrap();
|
|
||||||
fd.set_with_str("token", format!("{}", token).as_str())
|
|
||||||
.unwrap();
|
|
||||||
fd.set_with_blob("avatar", file).unwrap();
|
|
||||||
orders.perform_cmd(update_avatar(fd, model.host_url.clone()));
|
|
||||||
orders.skip();
|
|
||||||
}
|
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AvatarUrlChanged(
|
|
||||||
user_id,
|
|
||||||
avatar_url,
|
|
||||||
))) => {
|
|
||||||
if let Some(me) = model.user.as_mut() {
|
|
||||||
if me.id == user_id {
|
|
||||||
profile_page.avatar.url = Some(avatar_url.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm)) => {
|
|
||||||
send_ws_msg(
|
|
||||||
WsMsg::ProfileUpdate(
|
|
||||||
profile_page.email.value.clone(),
|
|
||||||
profile_page.name.value.clone(),
|
|
||||||
),
|
|
||||||
model.ws.as_ref(),
|
|
||||||
orders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|
||||||
send_ws_msg(WsMsg::ProjectRequest, model.ws.as_ref(), orders);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_page_content(model: &mut Model) {
|
|
||||||
let user = match model.user {
|
|
||||||
Some(ref user) => user,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
model.page_content = PageContent::Profile(Box::new(ProfilePage::new(user)));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(model: &Model) -> Node<Msg> {
|
|
||||||
let page = match &model.page_content {
|
|
||||||
PageContent::Profile(profile_page) => profile_page,
|
|
||||||
_ => return empty![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let avatar = StyledImageInput::build(FieldId::Profile(UsersFieldId::Avatar))
|
|
||||||
.add_class("avatar")
|
|
||||||
.state(&page.avatar)
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
|
|
||||||
let username = StyledInput::build(FieldId::Profile(UsersFieldId::Username))
|
|
||||||
.state(&page.name)
|
|
||||||
.valid(true)
|
|
||||||
.primary()
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
let username_field = StyledField::build()
|
|
||||||
.label("Username")
|
|
||||||
.input(username)
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
|
|
||||||
let email = StyledInput::build(FieldId::Profile(UsersFieldId::Username))
|
|
||||||
.state(&page.email)
|
|
||||||
.valid(true)
|
|
||||||
.primary()
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
let email_field = StyledField::build()
|
|
||||||
.label("E-Mail")
|
|
||||||
.input(email)
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
|
|
||||||
let submit = StyledButton::build()
|
|
||||||
.primary()
|
|
||||||
.text("Save")
|
|
||||||
.on_click(mouse_ev(Ev::Click, |ev| {
|
|
||||||
ev.prevent_default();
|
|
||||||
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
|
|
||||||
}))
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
let submit_field = StyledField::build().input(submit).build().into_node();
|
|
||||||
|
|
||||||
let content = StyledForm::build()
|
|
||||||
.heading("Profile")
|
|
||||||
.on_submit(ev(Ev::Submit, |ev| {
|
|
||||||
ev.prevent_default();
|
|
||||||
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
|
|
||||||
}))
|
|
||||||
.add_field(avatar)
|
|
||||||
.add_field(username_field)
|
|
||||||
.add_field(email_field)
|
|
||||||
.add_field(submit_field)
|
|
||||||
.build()
|
|
||||||
.into_node();
|
|
||||||
inner_layout(model, "profile", vec![content], empty![])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_avatar(data: FormData, host_url: String) -> Option<Msg> {
|
|
||||||
let path = format!("{}/avatar/", host_url);
|
|
||||||
let result = Request::new(path)
|
|
||||||
.method(Method::Post)
|
|
||||||
.body(data.into())
|
|
||||||
.fetch()
|
|
||||||
.await;
|
|
||||||
let response = match result {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
let text = response.text().await.ok()?;
|
|
||||||
Some(Msg::AvatarUpdateFetched(text))
|
|
||||||
}
|
|
5
jirs-client/src/profile/mod.rs
Normal file
5
jirs-client/src/profile/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod update;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
pub use update::update;
|
||||||
|
pub use view::view;
|
107
jirs-client/src/profile/update.rs
Normal file
107
jirs-client/src/profile/update.rs
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
use crate::model::{Model, Page, PageContent, ProfilePage};
|
||||||
|
use crate::ws::{enqueue_ws_msg, send_ws_msg};
|
||||||
|
use crate::{FieldId, Msg, PageChanged, ProfilePageChange, WebSocketChanged};
|
||||||
|
use jirs_data::{UsersFieldId, WsMsg};
|
||||||
|
use seed::prelude::{Method, Orders, Request};
|
||||||
|
use web_sys::FormData;
|
||||||
|
|
||||||
|
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
match msg {
|
||||||
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
|
||||||
|
| Msg::ChangePage(Page::Profile) => {
|
||||||
|
init_load(model, orders);
|
||||||
|
build_page_content(model);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile_page = match &mut model.page_content {
|
||||||
|
PageContent::Profile(profile_page) => profile_page,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
profile_page.name.update(&msg);
|
||||||
|
profile_page.email.update(&msg);
|
||||||
|
profile_page.avatar.update(&msg);
|
||||||
|
profile_page.current_project.update(&msg, orders);
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::FileInputChanged(FieldId::Profile(UsersFieldId::Avatar), ..) => {
|
||||||
|
let file = match profile_page.avatar.file.as_ref() {
|
||||||
|
Some(f) => f,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let token = match crate::shared::read_auth_token() {
|
||||||
|
Ok(uuid) => uuid,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let fd = FormData::new().unwrap();
|
||||||
|
fd.set_with_str("token", format!("{}", token).as_str())
|
||||||
|
.unwrap();
|
||||||
|
fd.set_with_blob("avatar", file).unwrap();
|
||||||
|
orders.perform_cmd(update_avatar(fd, model.host_url.clone()));
|
||||||
|
orders.skip();
|
||||||
|
}
|
||||||
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(ws_msg)) => match ws_msg {
|
||||||
|
WsMsg::AvatarUrlChanged(user_id, avatar_url) => {
|
||||||
|
if let Some(me) = model.user.as_mut() {
|
||||||
|
if me.id == user_id {
|
||||||
|
profile_page.avatar.url = Some(avatar_url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
},
|
||||||
|
Msg::ProjectChanged(Some(project)) => {
|
||||||
|
profile_page.current_project.values = vec![project.id as u32];
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm)) => {
|
||||||
|
send_ws_msg(
|
||||||
|
WsMsg::ProfileUpdate(
|
||||||
|
profile_page.email.value.clone(),
|
||||||
|
profile_page.name.value.clone(),
|
||||||
|
),
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
if model.user.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueue_ws_msg(vec![WsMsg::ProjectIssuesRequest], model.ws.as_ref(), orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_page_content(model: &mut Model) {
|
||||||
|
let user = match model.user {
|
||||||
|
Some(ref user) => user,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
model.page_content = PageContent::Profile(Box::new(ProfilePage::new(
|
||||||
|
user,
|
||||||
|
model
|
||||||
|
.project
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| vec![p.id])
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_avatar(data: FormData, host_url: String) -> Option<Msg> {
|
||||||
|
let path = format!("{}/avatar/", host_url);
|
||||||
|
let result = Request::new(path)
|
||||||
|
.method(Method::Post)
|
||||||
|
.body(data.into())
|
||||||
|
.fetch()
|
||||||
|
.await;
|
||||||
|
let response = match result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
let text = response.text().await.ok()?;
|
||||||
|
Some(Msg::AvatarUpdateFetched(text))
|
||||||
|
}
|
127
jirs-client/src/profile/view.rs
Normal file
127
jirs-client/src/profile/view.rs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
use seed::{prelude::*, *};
|
||||||
|
|
||||||
|
use jirs_data::*;
|
||||||
|
|
||||||
|
use crate::model::{Model, PageContent, ProfilePage};
|
||||||
|
use crate::shared::styled_button::StyledButton;
|
||||||
|
use crate::shared::styled_field::StyledField;
|
||||||
|
use crate::shared::styled_form::StyledForm;
|
||||||
|
use crate::shared::styled_image_input::StyledImageInput;
|
||||||
|
use crate::shared::styled_input::StyledInput;
|
||||||
|
use crate::shared::styled_select::StyledSelect;
|
||||||
|
use crate::shared::{inner_layout, ToChild, ToNode};
|
||||||
|
use crate::{FieldId, Msg, PageChanged, ProfilePageChange};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub fn view(model: &Model) -> Node<Msg> {
|
||||||
|
let page = match &model.page_content {
|
||||||
|
PageContent::Profile(profile_page) => profile_page,
|
||||||
|
_ => return empty![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let avatar = StyledImageInput::build(FieldId::Profile(UsersFieldId::Avatar))
|
||||||
|
.add_class("avatar")
|
||||||
|
.state(&page.avatar)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
|
let username = StyledInput::build(FieldId::Profile(UsersFieldId::Username))
|
||||||
|
.state(&page.name)
|
||||||
|
.valid(true)
|
||||||
|
.primary()
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let username_field = StyledField::build()
|
||||||
|
.label("Username")
|
||||||
|
.input(username)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
|
let email = StyledInput::build(FieldId::Profile(UsersFieldId::Username))
|
||||||
|
.state(&page.email)
|
||||||
|
.valid(true)
|
||||||
|
.primary()
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let email_field = StyledField::build()
|
||||||
|
.label("E-Mail")
|
||||||
|
.input(email)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
|
||||||
|
let current_project = build_current_project(model, page);
|
||||||
|
|
||||||
|
let submit = StyledButton::build()
|
||||||
|
.primary()
|
||||||
|
.text("Save")
|
||||||
|
.on_click(mouse_ev(Ev::Click, |ev| {
|
||||||
|
ev.prevent_default();
|
||||||
|
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
|
||||||
|
}))
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
let submit_field = StyledField::build().input(submit).build().into_node();
|
||||||
|
|
||||||
|
let content = StyledForm::build()
|
||||||
|
.heading("Profile")
|
||||||
|
.on_submit(ev(Ev::Submit, |ev| {
|
||||||
|
ev.prevent_default();
|
||||||
|
Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm))
|
||||||
|
}))
|
||||||
|
.add_field(avatar)
|
||||||
|
.add_field(username_field)
|
||||||
|
.add_field(email_field)
|
||||||
|
.add_field(current_project)
|
||||||
|
.add_field(submit_field)
|
||||||
|
.build()
|
||||||
|
.into_node();
|
||||||
|
inner_layout(model, "profile", vec![content], crate::modal::view(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_current_project(model: &Model, page: &Box<ProfilePage>) -> Node<Msg> {
|
||||||
|
let inner = if model.projects.len() <= 1 {
|
||||||
|
let name = model
|
||||||
|
.project
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.name.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
span![name]
|
||||||
|
} else {
|
||||||
|
let mut project_by_id = HashMap::new();
|
||||||
|
for p in model.projects.iter() {
|
||||||
|
project_by_id.insert(p.id, p);
|
||||||
|
}
|
||||||
|
let mut joined_projects = HashMap::new();
|
||||||
|
for p in model.user_projects.iter() {
|
||||||
|
joined_projects.insert(p.project_id, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSelect::build(FieldId::Profile(UsersFieldId::CurrentProject))
|
||||||
|
.name("current_project")
|
||||||
|
.normal()
|
||||||
|
.options(
|
||||||
|
model
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.filter_map(|project| {
|
||||||
|
joined_projects.get(&project.id).map(|_| project.to_child())
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.selected(
|
||||||
|
page.current_project
|
||||||
|
.values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| project_by_id.get(&((*id) as i32)).map(|p| p.to_child()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.state(&page.current_project)
|
||||||
|
.build()
|
||||||
|
.into_node()
|
||||||
|
};
|
||||||
|
StyledField::build()
|
||||||
|
.label("Current project")
|
||||||
|
.input(div![class!["project-name"], inner])
|
||||||
|
.build()
|
||||||
|
.into_node()
|
||||||
|
}
|
5
jirs-client/src/project/mod.rs
Normal file
5
jirs-client/src/project/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod update;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
pub use update::*;
|
||||||
|
pub use view::*;
|
139
jirs-client/src/project/update.rs
Normal file
139
jirs-client/src/project/update.rs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
use crate::model::{ModalType, Model, Page, PageContent, ProjectPage};
|
||||||
|
use crate::shared::styled_select::StyledSelectChange;
|
||||||
|
use crate::ws::{enqueue_ws_msg, send_ws_msg};
|
||||||
|
use crate::{BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged, WebSocketChanged};
|
||||||
|
use jirs_data::{Issue, IssueFieldId, WsMsg};
|
||||||
|
use seed::prelude::Orders;
|
||||||
|
|
||||||
|
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
if model.user.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::ChangePage(Page::Project)
|
||||||
|
| Msg::ChangePage(Page::AddIssue)
|
||||||
|
| Msg::ChangePage(Page::EditIssue(..)) => {
|
||||||
|
build_page_content(model);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_page = match &mut model.page_content {
|
||||||
|
PageContent::Project(project_page) => project_page,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
|
||||||
|
| Msg::ChangePage(Page::Project)
|
||||||
|
| Msg::ChangePage(Page::AddIssue)
|
||||||
|
| Msg::ChangePage(Page::EditIssue(..)) => {
|
||||||
|
init_load(model, orders);
|
||||||
|
}
|
||||||
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueUpdated(issue))) => {
|
||||||
|
let mut old: Vec<Issue> = vec![];
|
||||||
|
std::mem::swap(&mut old, &mut model.issues);
|
||||||
|
for is in old {
|
||||||
|
if is.id == issue.id {
|
||||||
|
model.issues.push(issue.clone())
|
||||||
|
} else {
|
||||||
|
model.issues.push(is);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueDeleted(id))) => {
|
||||||
|
let mut old: Vec<Issue> = vec![];
|
||||||
|
std::mem::swap(&mut old, &mut model.issues);
|
||||||
|
for is in old {
|
||||||
|
if is.id != id {
|
||||||
|
model.issues.push(is);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orders.skip().send_msg(Msg::ModalDropped);
|
||||||
|
}
|
||||||
|
Msg::StyledSelectChanged(
|
||||||
|
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
|
||||||
|
StyledSelectChange::Text(text),
|
||||||
|
) => {
|
||||||
|
let modal = model
|
||||||
|
.modals
|
||||||
|
.iter_mut()
|
||||||
|
.filter_map(|modal| match modal {
|
||||||
|
ModalType::EditIssue(_, modal) => Some(modal),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.last();
|
||||||
|
if let Some(m) = modal {
|
||||||
|
m.top_type_state.text_filter = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::StrInputChanged(FieldId::TextFilterBoard, text) => {
|
||||||
|
project_page.text_filter = text;
|
||||||
|
}
|
||||||
|
Msg::ProjectAvatarFilterChanged(user_id, active) => {
|
||||||
|
if active {
|
||||||
|
project_page.active_avatar_filters = project_page
|
||||||
|
.active_avatar_filters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| if *id != user_id { Some(*id) } else { None })
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
project_page.active_avatar_filters.push(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::ProjectToggleOnlyMy => {
|
||||||
|
project_page.only_my_filter = !project_page.only_my_filter;
|
||||||
|
}
|
||||||
|
Msg::ProjectToggleRecentlyUpdated => {
|
||||||
|
project_page.recently_updated_filter = !project_page.recently_updated_filter;
|
||||||
|
}
|
||||||
|
Msg::ProjectClearFilters => {
|
||||||
|
project_page.active_avatar_filters = vec![];
|
||||||
|
project_page.recently_updated_filter = false;
|
||||||
|
project_page.only_my_filter = false;
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(issue_id))) => {
|
||||||
|
crate::ws::issue::drag_started(issue_id, model)
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStopped(_))) => {
|
||||||
|
crate::ws::issue::sync(model, orders);
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::ExchangePosition(
|
||||||
|
issue_bellow_id,
|
||||||
|
))) => crate::ws::issue::exchange_position(issue_bellow_id, model),
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragOverStatus(status))) => {
|
||||||
|
crate::ws::issue::change_status(status, model)
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDropZone(_status))) => {
|
||||||
|
crate::ws::issue::sync(model, orders)
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::Board(BoardPageChange::DragLeave(_id))) => {
|
||||||
|
project_page.issue_drag.clear_last();
|
||||||
|
}
|
||||||
|
Msg::DeleteIssue(issue_id) => {
|
||||||
|
send_ws_msg(
|
||||||
|
jirs_data::WsMsg::IssueDeleteRequest(issue_id),
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
enqueue_ws_msg(
|
||||||
|
vec![
|
||||||
|
WsMsg::ProjectIssuesRequest,
|
||||||
|
WsMsg::ProjectUsersRequest,
|
||||||
|
WsMsg::IssueStatusesRequest,
|
||||||
|
],
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_page_content(model: &mut Model) {
|
||||||
|
model.page_content = PageContent::Project(Box::new(ProjectPage::default()));
|
||||||
|
}
|
@ -1,462 +1,335 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
|
|
||||||
use jirs_data::*;
|
use jirs_data::*;
|
||||||
|
|
||||||
use crate::model::{ModalType, Model, Page, PageContent, ProjectPage};
|
use crate::model::{Model, PageContent};
|
||||||
use crate::shared::styled_avatar::StyledAvatar;
|
use crate::shared::styled_avatar::StyledAvatar;
|
||||||
use crate::shared::styled_button::StyledButton;
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||||
use crate::shared::styled_input::StyledInput;
|
use crate::shared::styled_input::StyledInput;
|
||||||
use crate::shared::styled_select::StyledSelectChange;
|
use crate::shared::{inner_layout, ToNode};
|
||||||
use crate::shared::{inner_layout, ToNode};
|
use crate::{BoardPageChange, FieldId, Msg, PageChanged};
|
||||||
use crate::ws::{enqueue_ws_msg, send_ws_msg};
|
|
||||||
use crate::{BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged, WebSocketChanged};
|
pub fn view(model: &Model) -> Node<Msg> {
|
||||||
|
let project_section = vec![
|
||||||
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
|
breadcrumbs(model),
|
||||||
if model.user.is_none() {
|
header(),
|
||||||
return;
|
project_board_filters(model),
|
||||||
}
|
project_board_lists(model),
|
||||||
|
];
|
||||||
match msg {
|
|
||||||
Msg::ChangePage(Page::Project)
|
inner_layout(
|
||||||
| Msg::ChangePage(Page::AddIssue)
|
model,
|
||||||
| Msg::ChangePage(Page::EditIssue(..)) => {
|
"projectPage",
|
||||||
build_page_content(model);
|
project_section,
|
||||||
}
|
crate::modal::view(model),
|
||||||
_ => (),
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let project_page = match &mut model.page_content {
|
fn breadcrumbs(model: &Model) -> Node<Msg> {
|
||||||
PageContent::Project(project_page) => project_page,
|
let project_name = model
|
||||||
_ => return,
|
.project
|
||||||
};
|
.as_ref()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
match msg {
|
.unwrap_or_default();
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)))
|
div![
|
||||||
| Msg::ChangePage(Page::Project)
|
class!["breadcrumbsContainer"],
|
||||||
| Msg::ChangePage(Page::AddIssue)
|
span!["Projects"],
|
||||||
| Msg::ChangePage(Page::EditIssue(..)) => {
|
span![class!["breadcrumbsDivider"], "/"],
|
||||||
init_load(model, orders);
|
span![project_name],
|
||||||
}
|
span![class!["breadcrumbsDivider"], "/"],
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueUpdated(issue))) => {
|
span!["Kanban Board"]
|
||||||
let mut old: Vec<Issue> = vec![];
|
]
|
||||||
std::mem::swap(&mut old, &mut model.issues);
|
}
|
||||||
for is in old {
|
|
||||||
if is.id == issue.id {
|
fn header() -> Node<Msg> {
|
||||||
model.issues.push(issue.clone())
|
let button = StyledButton::build()
|
||||||
} else {
|
.secondary()
|
||||||
model.issues.push(is);
|
.text("Github Repo".to_string())
|
||||||
}
|
.icon(Icon::Github)
|
||||||
}
|
.build()
|
||||||
}
|
.into_node();
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::IssueDeleted(id))) => {
|
div![
|
||||||
let mut old: Vec<Issue> = vec![];
|
id!["projectBoardHeader"],
|
||||||
std::mem::swap(&mut old, &mut model.issues);
|
div![id!["boardName"], "Kanban board"],
|
||||||
for is in old {
|
a![
|
||||||
if is.id != id {
|
attrs![At::Href => "https://gitlab.com/adrian.wozniak/jirs", At::Target => "__blank", At::Rel => "noreferrer noopener"],
|
||||||
model.issues.push(is);
|
button
|
||||||
}
|
]
|
||||||
}
|
]
|
||||||
orders.skip().send_msg(Msg::ModalDropped);
|
}
|
||||||
}
|
|
||||||
Msg::StyledSelectChanged(
|
fn project_board_filters(model: &Model) -> Node<Msg> {
|
||||||
FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Type)),
|
let project_page = match &model.page_content {
|
||||||
StyledSelectChange::Text(text),
|
PageContent::Project(page_content) => page_content,
|
||||||
) => {
|
_ => return empty![],
|
||||||
let modal = model
|
};
|
||||||
.modals
|
|
||||||
.iter_mut()
|
let search_input = StyledInput::build(FieldId::TextFilterBoard)
|
||||||
.filter_map(|modal| match modal {
|
.icon(Icon::Search)
|
||||||
ModalType::EditIssue(_, modal) => Some(modal),
|
.valid(true)
|
||||||
_ => None,
|
.value(project_page.text_filter.as_str())
|
||||||
})
|
.build()
|
||||||
.last();
|
.into_node();
|
||||||
if let Some(m) = modal {
|
|
||||||
m.top_type_state.text_filter = text;
|
let only_my = StyledButton::build()
|
||||||
}
|
.empty()
|
||||||
}
|
.active(project_page.only_my_filter)
|
||||||
Msg::StrInputChanged(FieldId::TextFilterBoard, text) => {
|
.text("Only My Issues")
|
||||||
project_page.text_filter = text;
|
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy))
|
||||||
}
|
.build()
|
||||||
Msg::ProjectAvatarFilterChanged(user_id, active) => {
|
.into_node();
|
||||||
if active {
|
|
||||||
project_page.active_avatar_filters = project_page
|
let recently_updated = StyledButton::build()
|
||||||
.active_avatar_filters
|
.empty()
|
||||||
.iter()
|
.text("Recently Updated")
|
||||||
.filter_map(|id| if *id != user_id { Some(*id) } else { None })
|
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated))
|
||||||
.collect();
|
.build()
|
||||||
} else {
|
.into_node();
|
||||||
project_page.active_avatar_filters.push(user_id);
|
|
||||||
}
|
let clear_all = if project_page.only_my_filter
|
||||||
}
|
|| project_page.recently_updated_filter
|
||||||
Msg::ProjectToggleOnlyMy => {
|
|| !project_page.active_avatar_filters.is_empty()
|
||||||
project_page.only_my_filter = !project_page.only_my_filter;
|
{
|
||||||
}
|
seed::button![
|
||||||
Msg::ProjectToggleRecentlyUpdated => {
|
id!["clearAllFilters"],
|
||||||
project_page.recently_updated_filter = !project_page.recently_updated_filter;
|
"Clear all",
|
||||||
}
|
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
|
||||||
Msg::ProjectClearFilters => {
|
]
|
||||||
project_page.active_avatar_filters = vec![];
|
} else {
|
||||||
project_page.recently_updated_filter = false;
|
empty![]
|
||||||
project_page.only_my_filter = false;
|
};
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStarted(issue_id))) => {
|
div![
|
||||||
crate::ws::issue::drag_started(issue_id, model)
|
id!["projectBoardFilters"],
|
||||||
}
|
search_input,
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragStopped(_))) => {
|
avatars_filters(model),
|
||||||
crate::ws::issue::sync(model, orders);
|
only_my,
|
||||||
}
|
recently_updated,
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::ExchangePosition(
|
clear_all
|
||||||
issue_bellow_id,
|
]
|
||||||
))) => crate::ws::issue::exchange_position(issue_bellow_id, model),
|
}
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDragOverStatus(status))) => {
|
|
||||||
crate::ws::issue::change_status(status, model)
|
fn avatars_filters(model: &Model) -> Node<Msg> {
|
||||||
}
|
let project_page = match &model.page_content {
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::IssueDropZone(_status))) => {
|
PageContent::Project(project_page) => project_page,
|
||||||
crate::ws::issue::sync(model, orders)
|
_ => return empty![],
|
||||||
}
|
};
|
||||||
Msg::PageChanged(PageChanged::Board(BoardPageChange::DragLeave(_id))) => {
|
let active_avatar_filters = &project_page.active_avatar_filters;
|
||||||
project_page.issue_drag.clear_last();
|
let avatars: Vec<Node<Msg>> = model
|
||||||
}
|
.users
|
||||||
Msg::DeleteIssue(issue_id) => {
|
.iter()
|
||||||
send_ws_msg(
|
.enumerate()
|
||||||
jirs_data::WsMsg::IssueDeleteRequest(issue_id),
|
.map(|(idx, user)| {
|
||||||
model.ws.as_ref(),
|
let mut class_list = vec!["avatarIsActiveBorder"];
|
||||||
orders,
|
let user_id = user.id;
|
||||||
);
|
let active = active_avatar_filters.contains(&user_id);
|
||||||
}
|
if active {
|
||||||
_ => (),
|
class_list.push("isActive");
|
||||||
}
|
}
|
||||||
}
|
let styled_avatar = StyledAvatar::build()
|
||||||
|
.avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default())
|
||||||
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
.on_click(mouse_ev(Ev::Click, move |_| {
|
||||||
enqueue_ws_msg(
|
Msg::ProjectAvatarFilterChanged(user_id, active)
|
||||||
vec![
|
}))
|
||||||
jirs_data::WsMsg::ProjectRequest,
|
.name(user.name.as_str())
|
||||||
jirs_data::WsMsg::ProjectIssuesRequest,
|
.user_index(idx)
|
||||||
jirs_data::WsMsg::ProjectUsersRequest,
|
.build()
|
||||||
jirs_data::WsMsg::IssueStatusesRequest,
|
.into_node();
|
||||||
],
|
div![attrs![At::Class => class_list.join(" ")], styled_avatar]
|
||||||
model.ws.as_ref(),
|
})
|
||||||
orders,
|
.collect();
|
||||||
);
|
|
||||||
}
|
div![id!["avatars"], avatars]
|
||||||
|
}
|
||||||
fn build_page_content(model: &mut Model) {
|
|
||||||
model.page_content = PageContent::Project(Box::new(ProjectPage::default()));
|
fn project_board_lists(model: &Model) -> Node<Msg> {
|
||||||
}
|
let columns: Vec<Node<Msg>> = model
|
||||||
|
.issue_statuses
|
||||||
pub fn view(model: &Model) -> Node<Msg> {
|
.iter()
|
||||||
let project_section = vec![
|
.map(|is| project_issue_list(model, is))
|
||||||
breadcrumbs(model),
|
.collect();
|
||||||
header(),
|
div![id!["projectBoardLists"], columns]
|
||||||
project_board_filters(model),
|
}
|
||||||
project_board_lists(model),
|
|
||||||
];
|
fn project_issue_list(model: &Model, status: &jirs_data::IssueStatus) -> Node<Msg> {
|
||||||
|
let project_page = match &model.page_content {
|
||||||
inner_layout(
|
PageContent::Project(project_page) => project_page,
|
||||||
model,
|
_ => return empty![],
|
||||||
"projectPage",
|
};
|
||||||
project_section,
|
let ids: Vec<IssueId> = if project_page.recently_updated_filter {
|
||||||
crate::modal::view(model),
|
let mut v: Vec<(IssueId, NaiveDateTime)> = model
|
||||||
)
|
.issues
|
||||||
}
|
.iter()
|
||||||
|
.map(|issue| (issue.id, issue.updated_at))
|
||||||
fn breadcrumbs(model: &Model) -> Node<Msg> {
|
.collect();
|
||||||
let project_name = model
|
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
|
||||||
.project
|
if v.len() > 10 { v[0..10].to_vec() } else { v }
|
||||||
.as_ref()
|
.into_iter()
|
||||||
.map(|p| p.name.clone())
|
.map(|(id, _)| id)
|
||||||
.unwrap_or_default();
|
.collect()
|
||||||
div![
|
} else {
|
||||||
attrs![At::Class => "breadcrumbsContainer"],
|
model.issues.iter().map(|issue| issue.id).collect()
|
||||||
span!["Projects"],
|
};
|
||||||
span![attrs![At::Class => "breadcrumbsDivider"], "/"],
|
let issues: Vec<Node<Msg>> = model
|
||||||
span![project_name],
|
.issues
|
||||||
span![attrs![At::Class => "breadcrumbsDivider"], "/"],
|
.iter()
|
||||||
span!["Kanban Board"]
|
.filter(|issue| {
|
||||||
]
|
issue_filter_status(issue, status)
|
||||||
}
|
&& issue_filter_with_avatars(issue, &project_page.active_avatar_filters)
|
||||||
|
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
|
||||||
fn header() -> Node<Msg> {
|
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
|
||||||
let button = StyledButton::build()
|
&& issue_filter_with_only_recent(issue, ids.as_slice())
|
||||||
.secondary()
|
})
|
||||||
.text("Github Repo".to_string())
|
.map(|issue| project_issue(model, issue))
|
||||||
.icon(Icon::Github)
|
.collect();
|
||||||
.build()
|
let label = status.name.clone();
|
||||||
.into_node();
|
|
||||||
div![
|
let send_status = status.id;
|
||||||
id!["projectBoardHeader"],
|
let drop_handler = drag_ev(Ev::Drop, move |ev| {
|
||||||
div![id!["boardName"], "Kanban board"],
|
ev.prevent_default();
|
||||||
a![
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
attrs![At::Href => "https://gitlab.com/adrian.wozniak/jirs", At::Target => "__blank", At::Rel => "noreferrer noopener"],
|
BoardPageChange::IssueDropZone(send_status),
|
||||||
button
|
)))
|
||||||
]
|
});
|
||||||
]
|
|
||||||
}
|
let send_status = status.id;
|
||||||
|
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
||||||
fn project_board_filters(model: &Model) -> Node<Msg> {
|
ev.prevent_default();
|
||||||
let project_page = match &model.page_content {
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
PageContent::Project(page_content) => page_content,
|
BoardPageChange::IssueDragOverStatus(send_status),
|
||||||
_ => return empty![],
|
)))
|
||||||
};
|
});
|
||||||
|
|
||||||
let search_input = StyledInput::build(FieldId::TextFilterBoard)
|
div![
|
||||||
.icon(Icon::Search)
|
attrs![At::Class => "list";],
|
||||||
.valid(true)
|
div![
|
||||||
.value(project_page.text_filter.as_str())
|
attrs![At::Class => "title"],
|
||||||
.build()
|
label,
|
||||||
.into_node();
|
div![attrs![At::Class => "issuesCount"]]
|
||||||
|
],
|
||||||
let only_my = StyledButton::build()
|
div![
|
||||||
.empty()
|
attrs![At::Class => "issues"; At::DropZone => "link"],
|
||||||
.active(project_page.only_my_filter)
|
drop_handler,
|
||||||
.text("Only My Issues")
|
drag_over_handler,
|
||||||
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy))
|
issues
|
||||||
.build()
|
]
|
||||||
.into_node();
|
]
|
||||||
|
}
|
||||||
let recently_updated = StyledButton::build()
|
|
||||||
.empty()
|
#[inline]
|
||||||
.text("Recently Updated")
|
fn issue_filter_with_avatars(issue: &Issue, user_ids: &Vec<UserId>) -> bool {
|
||||||
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated))
|
if user_ids.is_empty() {
|
||||||
.build()
|
return true;
|
||||||
.into_node();
|
}
|
||||||
|
user_ids.contains(&issue.reporter_id) || issue.user_ids.iter().any(|id| user_ids.contains(id))
|
||||||
let clear_all = if project_page.only_my_filter
|
}
|
||||||
|| project_page.recently_updated_filter
|
|
||||||
|| !project_page.active_avatar_filters.is_empty()
|
#[inline]
|
||||||
{
|
fn issue_filter_status(issue: &Issue, status: &IssueStatus) -> bool {
|
||||||
seed::button![
|
issue.issue_status_id == status.id
|
||||||
id!["clearAllFilters"],
|
}
|
||||||
"Clear all",
|
|
||||||
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
|
#[inline]
|
||||||
]
|
fn issue_filter_with_text(issue: &Issue, text: &str) -> bool {
|
||||||
} else {
|
text.is_empty() || issue.title.contains(text)
|
||||||
empty![]
|
}
|
||||||
};
|
|
||||||
|
#[inline]
|
||||||
div![
|
fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>) -> bool {
|
||||||
id!["projectBoardFilters"],
|
let my_id = user.as_ref().map(|u| u.id).unwrap_or_default();
|
||||||
search_input,
|
!only_my || issue.user_ids.contains(&my_id)
|
||||||
avatars_filters(model),
|
}
|
||||||
only_my,
|
|
||||||
recently_updated,
|
#[inline]
|
||||||
clear_all
|
fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
|
||||||
]
|
ids.is_empty() || ids.contains(&issue.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn avatars_filters(model: &Model) -> Node<Msg> {
|
fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
|
||||||
let project_page = match &model.page_content {
|
let avatars: Vec<Node<Msg>> = model
|
||||||
PageContent::Project(project_page) => project_page,
|
.users
|
||||||
_ => return empty![],
|
.iter()
|
||||||
};
|
.enumerate()
|
||||||
let active_avatar_filters = &project_page.active_avatar_filters;
|
.filter(|(_, user)| issue.user_ids.contains(&user.id))
|
||||||
let avatars: Vec<Node<Msg>> = model
|
.map(|(idx, user)| {
|
||||||
.users
|
StyledAvatar::build()
|
||||||
.iter()
|
.size(24)
|
||||||
.enumerate()
|
.name(user.name.as_str())
|
||||||
.map(|(idx, user)| {
|
.avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default())
|
||||||
let mut class_list = vec!["avatarIsActiveBorder"];
|
.user_index(idx)
|
||||||
let user_id = user.id;
|
.build()
|
||||||
let active = active_avatar_filters.contains(&user_id);
|
.into_node()
|
||||||
if active {
|
})
|
||||||
class_list.push("isActive");
|
.collect();
|
||||||
}
|
|
||||||
let styled_avatar = StyledAvatar::build()
|
let issue_type_icon = {
|
||||||
.avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default())
|
StyledIcon::build(issue.issue_type.clone().into())
|
||||||
.on_click(mouse_ev(Ev::Click, move |_| {
|
.add_style(format!(
|
||||||
Msg::ProjectAvatarFilterChanged(user_id, active)
|
"color: var(--{issue_type})",
|
||||||
}))
|
issue_type = issue.issue_type.to_string()
|
||||||
.name(user.name.as_str())
|
))
|
||||||
.user_index(idx)
|
.build()
|
||||||
.build()
|
.into_node()
|
||||||
.into_node();
|
};
|
||||||
div![attrs![At::Class => class_list.join(" ")], styled_avatar]
|
let priority_icon = {
|
||||||
})
|
let icon = match issue.priority {
|
||||||
.collect();
|
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
|
||||||
|
_ => Icon::ArrowUp,
|
||||||
div![id!["avatars"], avatars]
|
};
|
||||||
}
|
StyledIcon::build(icon)
|
||||||
|
.add_style(format!("color: var(--{})", issue.priority))
|
||||||
fn project_board_lists(model: &Model) -> Node<Msg> {
|
.build()
|
||||||
let columns: Vec<Node<Msg>> = model
|
.into_node()
|
||||||
.issue_statuses
|
};
|
||||||
.iter()
|
|
||||||
.map(|is| project_issue_list(model, is))
|
let issue_id = issue.id;
|
||||||
.collect();
|
let drag_started = drag_ev(Ev::DragStart, move |_| {
|
||||||
div![id!["projectBoardLists"], columns]
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
}
|
BoardPageChange::IssueDragStarted(issue_id),
|
||||||
|
)))
|
||||||
fn project_issue_list(model: &Model, status: &jirs_data::IssueStatus) -> Node<Msg> {
|
});
|
||||||
let project_page = match &model.page_content {
|
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
|
||||||
PageContent::Project(project_page) => project_page,
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
_ => return empty![],
|
BoardPageChange::IssueDragStopped(issue_id),
|
||||||
};
|
)))
|
||||||
let ids: Vec<IssueId> = if project_page.recently_updated_filter {
|
});
|
||||||
let mut v: Vec<(IssueId, NaiveDateTime)> = model
|
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
||||||
.issues
|
ev.prevent_default();
|
||||||
.iter()
|
ev.stop_propagation();
|
||||||
.map(|issue| (issue.id, issue.updated_at))
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
.collect();
|
BoardPageChange::ExchangePosition(issue_id),
|
||||||
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
|
)))
|
||||||
if v.len() > 10 { v[0..10].to_vec() } else { v }
|
});
|
||||||
.into_iter()
|
let issue_id = issue.id;
|
||||||
.map(|(id, _)| id)
|
let drag_out = drag_ev(Ev::DragLeave, move |_| {
|
||||||
.collect()
|
Some(Msg::PageChanged(PageChanged::Board(
|
||||||
} else {
|
BoardPageChange::DragLeave(issue_id),
|
||||||
model.issues.iter().map(|issue| issue.id).collect()
|
)))
|
||||||
};
|
});
|
||||||
let issues: Vec<Node<Msg>> = model
|
|
||||||
.issues
|
let class_list = vec!["issue"];
|
||||||
.iter()
|
|
||||||
.filter(|issue| {
|
let href = format!("/issues/{id}", id = issue_id);
|
||||||
issue_filter_status(issue, status)
|
|
||||||
&& issue_filter_with_text(issue, project_page.text_filter.as_str())
|
a![
|
||||||
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
|
drag_started,
|
||||||
&& issue_filter_with_only_recent(issue, ids.as_slice())
|
attrs![At::Class => "issueLink"; At::Href => href],
|
||||||
})
|
div![
|
||||||
.map(|issue| project_issue(model, issue))
|
attrs![At::Class => class_list.join(" "), At::Draggable => true],
|
||||||
.collect();
|
drag_stopped,
|
||||||
let label = status.name.clone();
|
drag_over_handler,
|
||||||
|
drag_out,
|
||||||
let send_status = status.id;
|
p![attrs![At::Class => "title"], issue.title.as_str()],
|
||||||
let drop_handler = drag_ev(Ev::Drop, move |ev| {
|
div![
|
||||||
ev.prevent_default();
|
attrs![At::Class => "bottom"],
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
div![
|
||||||
BoardPageChange::IssueDropZone(send_status),
|
div![attrs![At::Class => "issueTypeIcon"], issue_type_icon],
|
||||||
)))
|
div![attrs![At::Class => "issuePriorityIcon"], priority_icon]
|
||||||
});
|
],
|
||||||
|
div![attrs![At::Class => "assignees"], avatars,],
|
||||||
let send_status = status.id;
|
]
|
||||||
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
]
|
||||||
ev.prevent_default();
|
]
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
}
|
||||||
BoardPageChange::IssueDragOverStatus(send_status),
|
|
||||||
)))
|
|
||||||
});
|
|
||||||
|
|
||||||
div![
|
|
||||||
attrs![At::Class => "list";],
|
|
||||||
div![
|
|
||||||
attrs![At::Class => "title"],
|
|
||||||
label,
|
|
||||||
div![attrs![At::Class => "issuesCount"]]
|
|
||||||
],
|
|
||||||
div![
|
|
||||||
attrs![At::Class => "issues"; At::DropZone => "link"],
|
|
||||||
drop_handler,
|
|
||||||
drag_over_handler,
|
|
||||||
issues
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn issue_filter_status(issue: &Issue, status: &IssueStatus) -> bool {
|
|
||||||
issue.issue_status_id == status.id
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn issue_filter_with_text(issue: &Issue, text: &str) -> bool {
|
|
||||||
text.is_empty() || issue.title.contains(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>) -> bool {
|
|
||||||
let my_id = user.as_ref().map(|u| u.id).unwrap_or_default();
|
|
||||||
!only_my || issue.user_ids.contains(&my_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
|
|
||||||
ids.is_empty() || ids.contains(&issue.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_issue(model: &Model, issue: &Issue) -> Node<Msg> {
|
|
||||||
let avatars: Vec<Node<Msg>> = model
|
|
||||||
.users
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, user)| issue.user_ids.contains(&user.id))
|
|
||||||
.map(|(idx, user)| {
|
|
||||||
StyledAvatar::build()
|
|
||||||
.size(24)
|
|
||||||
.name(user.name.as_str())
|
|
||||||
.avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default())
|
|
||||||
.user_index(idx)
|
|
||||||
.build()
|
|
||||||
.into_node()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let issue_type_icon = {
|
|
||||||
StyledIcon::build(issue.issue_type.clone().into())
|
|
||||||
.add_style(format!(
|
|
||||||
"color: var(--{issue_type})",
|
|
||||||
issue_type = issue.issue_type.to_string()
|
|
||||||
))
|
|
||||||
.build()
|
|
||||||
.into_node()
|
|
||||||
};
|
|
||||||
let priority_icon = {
|
|
||||||
let icon = match issue.priority {
|
|
||||||
IssuePriority::Low | IssuePriority::Lowest => Icon::ArrowDown,
|
|
||||||
_ => Icon::ArrowUp,
|
|
||||||
};
|
|
||||||
StyledIcon::build(icon)
|
|
||||||
.add_style(format!("color: var(--{})", issue.priority))
|
|
||||||
.build()
|
|
||||||
.into_node()
|
|
||||||
};
|
|
||||||
|
|
||||||
let issue_id = issue.id;
|
|
||||||
let drag_started = drag_ev(Ev::DragStart, move |_| {
|
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
|
||||||
BoardPageChange::IssueDragStarted(issue_id),
|
|
||||||
)))
|
|
||||||
});
|
|
||||||
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
|
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
|
||||||
BoardPageChange::IssueDragStopped(issue_id),
|
|
||||||
)))
|
|
||||||
});
|
|
||||||
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
|
||||||
ev.prevent_default();
|
|
||||||
ev.stop_propagation();
|
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
|
||||||
BoardPageChange::ExchangePosition(issue_id),
|
|
||||||
)))
|
|
||||||
});
|
|
||||||
let issue_id = issue.id;
|
|
||||||
let drag_out = drag_ev(Ev::DragLeave, move |_| {
|
|
||||||
Some(Msg::PageChanged(PageChanged::Board(
|
|
||||||
BoardPageChange::DragLeave(issue_id),
|
|
||||||
)))
|
|
||||||
});
|
|
||||||
|
|
||||||
let class_list = vec!["issue"];
|
|
||||||
|
|
||||||
let href = format!("/issues/{id}", id = issue_id);
|
|
||||||
|
|
||||||
a![
|
|
||||||
drag_started,
|
|
||||||
attrs![At::Class => "issueLink"; At::Href => href],
|
|
||||||
div![
|
|
||||||
attrs![At::Class => class_list.join(" "), At::Draggable => true],
|
|
||||||
drag_stopped,
|
|
||||||
drag_over_handler,
|
|
||||||
drag_out,
|
|
||||||
p![attrs![At::Class => "title"], issue.title.as_str()],
|
|
||||||
div![
|
|
||||||
attrs![At::Class => "bottom"],
|
|
||||||
div![
|
|
||||||
div![attrs![At::Class => "issueTypeIcon"], issue_type_icon],
|
|
||||||
div![attrs![At::Class => "issuePriorityIcon"], priority_icon]
|
|
||||||
],
|
|
||||||
div![attrs![At::Class => "assignees"], avatars,],
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
5
jirs-client/src/project_settings/mod.rs
Normal file
5
jirs-client/src/project_settings/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod update;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
pub use update::update;
|
||||||
|
pub use view::view;
|
246
jirs-client/src/project_settings/update.rs
Normal file
246
jirs-client/src/project_settings/update.rs
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
use crate::model::{Model, Page, PageContent, ProjectSettingsPage};
|
||||||
|
use crate::shared::styled_select::StyledSelectChange;
|
||||||
|
use crate::ws::{enqueue_ws_msg, send_ws_msg};
|
||||||
|
use crate::FieldChange::TabChanged;
|
||||||
|
use crate::{FieldId, Msg, PageChanged, ProjectPageChange, WebSocketChanged};
|
||||||
|
use jirs_data::{IssueStatus, IssueStatusId, ProjectFieldId, UpdateProjectPayload, WsMsg};
|
||||||
|
use seed::error;
|
||||||
|
use seed::prelude::Orders;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
if model.page != Page::ProjectSettings {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::ProjectChanged(Some(_)) => {
|
||||||
|
build_page_content(model);
|
||||||
|
}
|
||||||
|
Msg::WebSocketChange(ref change) => match change {
|
||||||
|
WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
|
||||||
|
init_load(model, orders);
|
||||||
|
}
|
||||||
|
WebSocketChanged::WsMsg(WsMsg::IssueStatusCreated(_)) => {
|
||||||
|
match &mut model.page_content {
|
||||||
|
PageContent::ProjectSettings(page) if Some(0) == page.edit_column_id => {
|
||||||
|
page.reset();
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
},
|
||||||
|
Msg::ChangePage(Page::ProjectSettings) => {
|
||||||
|
build_page_content(model);
|
||||||
|
if model.user.is_some() {
|
||||||
|
init_load(model, orders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.user.is_none() || model.project.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = match &mut model.page_content {
|
||||||
|
PageContent::ProjectSettings(page) => page,
|
||||||
|
_ => return error!("bad content type"),
|
||||||
|
};
|
||||||
|
page.project_category_state.update(&msg, orders);
|
||||||
|
page.time_tracking.update(&msg);
|
||||||
|
page.name.update(&msg);
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Name), text) => {
|
||||||
|
page.payload.name = Some(text);
|
||||||
|
}
|
||||||
|
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Url), text) => {
|
||||||
|
page.payload.url = Some(text);
|
||||||
|
}
|
||||||
|
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Description), text) => {
|
||||||
|
page.payload.description = Some(text);
|
||||||
|
}
|
||||||
|
Msg::StyledSelectChanged(
|
||||||
|
FieldId::ProjectSettings(ProjectFieldId::Category),
|
||||||
|
StyledSelectChange::Changed(value),
|
||||||
|
) => {
|
||||||
|
let category = value.into();
|
||||||
|
page.payload.category = Some(category);
|
||||||
|
}
|
||||||
|
Msg::ModalChanged(TabChanged(
|
||||||
|
FieldId::ProjectSettings(ProjectFieldId::Description),
|
||||||
|
mode,
|
||||||
|
)) => {
|
||||||
|
page.description_mode = mode;
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
|
ProjectPageChange::SubmitProjectSettingsForm,
|
||||||
|
)) => {
|
||||||
|
send_ws_msg(
|
||||||
|
WsMsg::ProjectUpdateRequest(UpdateProjectPayload {
|
||||||
|
id: page.payload.id,
|
||||||
|
name: page.payload.name.clone(),
|
||||||
|
url: page.payload.url.clone(),
|
||||||
|
description: page.payload.description.clone(),
|
||||||
|
category: page.payload.category.clone(),
|
||||||
|
time_tracking: Some(page.time_tracking.value.into()),
|
||||||
|
}),
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStarted(
|
||||||
|
issue_status_id,
|
||||||
|
))) => {
|
||||||
|
page.column_drag.drag(issue_status_id);
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStopped(
|
||||||
|
_issue_status_id,
|
||||||
|
))) => {
|
||||||
|
sync(model, orders);
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragLeave(
|
||||||
|
_issue_status_id,
|
||||||
|
))) => page.column_drag.clear_last(),
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
|
ProjectPageChange::ColumnExchangePosition(issue_bellow_id),
|
||||||
|
)) => exchange_position(issue_bellow_id, model),
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDropZone(
|
||||||
|
_issue_status_id,
|
||||||
|
))) => {
|
||||||
|
sync(model, orders);
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::EditIssueStatusName(
|
||||||
|
id,
|
||||||
|
))) => {
|
||||||
|
if page.edit_column_id.is_some() && id.is_none() {
|
||||||
|
let old_id = page.edit_column_id.as_ref().cloned();
|
||||||
|
let name = page.name.value.clone();
|
||||||
|
if let Some((id, pos)) = model
|
||||||
|
.issue_statuses
|
||||||
|
.iter()
|
||||||
|
.find(|is| Some(is.id) == old_id)
|
||||||
|
.map(|is| (is.id, is.position))
|
||||||
|
{
|
||||||
|
send_ws_msg(
|
||||||
|
WsMsg::IssueStatusUpdate(id, name.to_string(), pos),
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.name.value = model
|
||||||
|
.issue_statuses
|
||||||
|
.iter()
|
||||||
|
.find_map(|is| {
|
||||||
|
if Some(is.id) == id {
|
||||||
|
Some(is.name.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
page.edit_column_id = id;
|
||||||
|
}
|
||||||
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
|
ProjectPageChange::SubmitIssueStatusForm,
|
||||||
|
)) => {
|
||||||
|
let name = page.name.value.clone();
|
||||||
|
let position = model.issue_statuses.len();
|
||||||
|
let ws_msg = WsMsg::IssueStatusCreate(name, position as i32);
|
||||||
|
send_ws_msg(ws_msg, model.ws.as_ref(), orders);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
enqueue_ws_msg(
|
||||||
|
vec![WsMsg::IssueStatusesRequest, WsMsg::ProjectIssuesRequest],
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange_position(bellow_id: IssueStatusId, model: &mut Model) {
|
||||||
|
let page = match &mut model.page_content {
|
||||||
|
PageContent::ProjectSettings(page) => page,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
if page.column_drag.dragged_or_last(bellow_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dragged_id = match page.column_drag.dragged_id.as_ref().cloned() {
|
||||||
|
Some(id) => id,
|
||||||
|
_ => return error!("Nothing is dragged"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut below = None;
|
||||||
|
let mut dragged = None;
|
||||||
|
let mut issues_statuses = vec![];
|
||||||
|
std::mem::swap(&mut issues_statuses, &mut model.issue_statuses);
|
||||||
|
|
||||||
|
for issue_status in issues_statuses.into_iter() {
|
||||||
|
match issue_status.id {
|
||||||
|
id if id == bellow_id => below = Some(issue_status),
|
||||||
|
id if id == dragged_id => dragged = Some(issue_status),
|
||||||
|
_ => model.issue_statuses.push(issue_status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut below = match below {
|
||||||
|
Some(below) => below,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let mut dragged = match dragged {
|
||||||
|
Some(issue_status) => issue_status,
|
||||||
|
_ => {
|
||||||
|
model.issue_statuses.push(below);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::mem::swap(&mut dragged.position, &mut below.position);
|
||||||
|
|
||||||
|
page.column_drag.mark_dirty(dragged.id);
|
||||||
|
page.column_drag.mark_dirty(below.id);
|
||||||
|
|
||||||
|
model.issue_statuses.push(below);
|
||||||
|
model.issue_statuses.push(dragged);
|
||||||
|
model
|
||||||
|
.issue_statuses
|
||||||
|
.sort_by(|a, b| a.position.cmp(&b.position));
|
||||||
|
page.column_drag.last_id = Some(bellow_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
let dirty = match &mut model.page_content {
|
||||||
|
PageContent::ProjectSettings(page) => {
|
||||||
|
let mut old = HashSet::new();
|
||||||
|
std::mem::swap(&mut old, &mut page.column_drag.dirty);
|
||||||
|
old
|
||||||
|
}
|
||||||
|
_ => return error!("bad content type"),
|
||||||
|
};
|
||||||
|
for id in dirty {
|
||||||
|
let IssueStatus { name, position, .. } =
|
||||||
|
match model.issue_statuses.iter().find(|is| is.id == id) {
|
||||||
|
Some(is) => is,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
send_ws_msg(
|
||||||
|
WsMsg::IssueStatusUpdate(id, name.clone(), *position),
|
||||||
|
model.ws.as_ref(),
|
||||||
|
orders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_page_content(model: &mut Model) {
|
||||||
|
let project = match &model.project {
|
||||||
|
Some(project) => project,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
model.page_content = PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(project)));
|
||||||
|
}
|
@ -1,617 +1,358 @@
|
|||||||
use std::collections::HashSet;
|
use seed::{prelude::*, *};
|
||||||
|
use std::collections::HashMap;
|
||||||
use seed::{prelude::*, *};
|
|
||||||
use wasm_bindgen::__rt::std::collections::HashMap;
|
use jirs_data::{IssueStatus, ProjectCategory, TimeTracking, ToVec};
|
||||||
|
|
||||||
use jirs_data::{
|
use crate::model::{DeleteIssueStatusModal, ModalType, Model, PageContent, ProjectSettingsPage};
|
||||||
IssueStatus, IssueStatusId, ProjectCategory, TimeTracking, ToVec, UpdateProjectPayload, WsMsg,
|
use crate::shared::styled_button::StyledButton;
|
||||||
};
|
use crate::shared::styled_checkbox::StyledCheckbox;
|
||||||
|
use crate::shared::styled_editor::StyledEditor;
|
||||||
use crate::model::{
|
use crate::shared::styled_field::StyledField;
|
||||||
DeleteIssueStatusModal, ModalType, Model, Page, PageContent, ProjectSettingsPage,
|
use crate::shared::styled_form::StyledForm;
|
||||||
};
|
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||||
use crate::shared::styled_button::StyledButton;
|
use crate::shared::styled_input::StyledInput;
|
||||||
use crate::shared::styled_checkbox::StyledCheckbox;
|
use crate::shared::styled_select::StyledSelect;
|
||||||
use crate::shared::styled_editor::StyledEditor;
|
use crate::shared::styled_textarea::StyledTextarea;
|
||||||
use crate::shared::styled_field::StyledField;
|
use crate::shared::{inner_layout, ToChild, ToNode};
|
||||||
use crate::shared::styled_form::StyledForm;
|
use crate::{model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange};
|
||||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
|
||||||
use crate::shared::styled_input::StyledInput;
|
static TIME_TRACKING_FIBONACCI: &'static str = "Tracking employees’ time carries the risk of having them feel like they are being spied on. This is one of the most common fears that employees have when a time tracking system is implemented. No one likes to feel like they’re always being watched.";
|
||||||
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
|
static TIME_TRACKING_HOURLY: &'static str = "Employees may feel intimidated by demands to track their time. Or they could feel that they’re constantly being watched and evaluated. And for overly ambitious managers, employee time tracking may open the doors to excessive micromanaging.";
|
||||||
use crate::shared::styled_textarea::StyledTextarea;
|
|
||||||
use crate::shared::{inner_layout, ToChild, ToNode};
|
pub fn view(model: &model::Model) -> Node<Msg> {
|
||||||
use crate::ws::{enqueue_ws_msg, send_ws_msg};
|
let page = match &model.page_content {
|
||||||
use crate::FieldChange::TabChanged;
|
PageContent::ProjectSettings(page) => page,
|
||||||
use crate::{
|
_ => return empty![],
|
||||||
model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange, WebSocketChanged,
|
};
|
||||||
};
|
let name_field = name_field(page);
|
||||||
|
|
||||||
//######################################
|
let url_field = url_field(page);
|
||||||
// VIEW
|
|
||||||
//######################################
|
let description_field = description_field(page);
|
||||||
|
|
||||||
static TIME_TRACKING_FIBONACCI: &'static str = "Tracking employees’ time carries the risk of having them feel like they are being spied on. This is one of the most common fears that employees have when a time tracking system is implemented. No one likes to feel like they’re always being watched.";
|
let category_field = category_field(page);
|
||||||
static TIME_TRACKING_HOURLY: &'static str = "Employees may feel intimidated by demands to track their time. Or they could feel that they’re constantly being watched and evaluated. And for overly ambitious managers, employee time tracking may open the doors to excessive micromanaging.";
|
|
||||||
|
let time_tracking =
|
||||||
pub fn view(model: &model::Model) -> Node<Msg> {
|
StyledCheckbox::build(FieldId::ProjectSettings(ProjectFieldId::TimeTracking))
|
||||||
let page = match &model.page_content {
|
.options(vec![
|
||||||
PageContent::ProjectSettings(page) => page,
|
TimeTracking::Untracked.to_child(),
|
||||||
_ => return empty![],
|
TimeTracking::Fibonacci.to_child(),
|
||||||
};
|
TimeTracking::Hourly.to_child(),
|
||||||
let name_field = name_field(page);
|
])
|
||||||
|
.state(&page.time_tracking)
|
||||||
let url_field = url_field(page);
|
.add_class("timeTracking")
|
||||||
|
.build()
|
||||||
let description_field = description_field(page);
|
.into_node();
|
||||||
|
let time_tracking_type: TimeTracking = page.time_tracking.value.into();
|
||||||
let category_field = category_field(page);
|
let time_tracking_field = StyledField::build()
|
||||||
|
.input(time_tracking)
|
||||||
let time_tracking =
|
.tip(match time_tracking_type {
|
||||||
StyledCheckbox::build(FieldId::ProjectSettings(ProjectFieldId::TimeTracking))
|
TimeTracking::Fibonacci => TIME_TRACKING_FIBONACCI,
|
||||||
.options(vec![
|
TimeTracking::Hourly => TIME_TRACKING_HOURLY,
|
||||||
TimeTracking::Untracked.to_child(),
|
_ => "",
|
||||||
TimeTracking::Fibonacci.to_child(),
|
})
|
||||||
TimeTracking::Hourly.to_child(),
|
.build()
|
||||||
])
|
.into_node();
|
||||||
.state(&page.time_tracking)
|
|
||||||
.add_class("timeTracking")
|
let columns_field = columns_section(model, page);
|
||||||
.build()
|
|
||||||
.into_node();
|
let save_button = StyledButton::build()
|
||||||
let time_tracking_type: TimeTracking = page.time_tracking.value.into();
|
.add_class("actionButton")
|
||||||
let time_tracking_field = StyledField::build()
|
.on_click(mouse_ev(Ev::Click, |ev| {
|
||||||
.input(time_tracking)
|
ev.prevent_default();
|
||||||
.tip(match time_tracking_type {
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
TimeTracking::Fibonacci => TIME_TRACKING_FIBONACCI,
|
ProjectPageChange::SubmitProjectSettingsForm,
|
||||||
TimeTracking::Hourly => TIME_TRACKING_HOURLY,
|
))
|
||||||
_ => "",
|
}))
|
||||||
})
|
.text("Save changes")
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
|
||||||
let columns_field = columns_section(model, page);
|
let form = StyledForm::build()
|
||||||
|
.heading("Project Details")
|
||||||
let save_button = StyledButton::build()
|
.on_submit(ev(Ev::Submit, |ev| {
|
||||||
.add_class("actionButton")
|
ev.prevent_default();
|
||||||
.on_click(mouse_ev(Ev::Click, |ev| {
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
ev.prevent_default();
|
ProjectPageChange::SubmitProjectSettingsForm,
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
))
|
||||||
ProjectPageChange::SubmitProjectSettingsForm,
|
}))
|
||||||
))
|
.add_field(name_field)
|
||||||
}))
|
.add_field(url_field)
|
||||||
.text("Save changes")
|
.add_field(description_field)
|
||||||
.build()
|
.add_field(category_field)
|
||||||
.into_node();
|
.add_field(time_tracking_field)
|
||||||
|
.add_field(save_button)
|
||||||
let form = StyledForm::build()
|
.add_field(columns_field)
|
||||||
.heading("Project Details")
|
.build()
|
||||||
.on_submit(ev(Ev::Submit, |ev| {
|
.into_node();
|
||||||
ev.prevent_default();
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
let project_section = vec![div![class!["formContainer"], form]];
|
||||||
ProjectPageChange::SubmitProjectSettingsForm,
|
|
||||||
))
|
inner_layout(
|
||||||
}))
|
model,
|
||||||
.add_field(name_field)
|
"projectSettings",
|
||||||
.add_field(url_field)
|
project_section,
|
||||||
.add_field(description_field)
|
crate::modal::view(model),
|
||||||
.add_field(category_field)
|
)
|
||||||
.add_field(time_tracking_field)
|
}
|
||||||
.add_field(save_button)
|
|
||||||
.add_field(columns_field)
|
/// Build project name input with styled field wrapper
|
||||||
.build()
|
fn name_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
||||||
.into_node();
|
let name = StyledTextarea::build(FieldId::ProjectSettings(ProjectFieldId::Name))
|
||||||
|
.value(page.payload.name.as_ref().cloned().unwrap_or_default())
|
||||||
let project_section = vec![div![class!["formContainer"], form]];
|
.height(39)
|
||||||
|
.max_height(39)
|
||||||
inner_layout(
|
.disable_auto_resize()
|
||||||
model,
|
.build()
|
||||||
"projectSettings",
|
.into_node();
|
||||||
project_section,
|
StyledField::build()
|
||||||
crate::modal::view(model),
|
.label("Name")
|
||||||
)
|
.input(name)
|
||||||
}
|
.tip("")
|
||||||
|
.build()
|
||||||
/// Build project name input with styled field wrapper
|
.into_node()
|
||||||
fn name_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
}
|
||||||
let name = StyledTextarea::build(FieldId::ProjectSettings(ProjectFieldId::Name))
|
|
||||||
.value(page.payload.name.as_ref().cloned().unwrap_or_default())
|
/// Build project url input with styled field wrapper
|
||||||
.height(39)
|
fn url_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
||||||
.max_height(39)
|
let url = StyledTextarea::build(FieldId::ProjectSettings(ProjectFieldId::Url))
|
||||||
.disable_auto_resize()
|
.height(39)
|
||||||
.build()
|
.max_height(39)
|
||||||
.into_node();
|
.disable_auto_resize()
|
||||||
StyledField::build()
|
.value(page.payload.url.as_ref().cloned().unwrap_or_default())
|
||||||
.label("Name")
|
.build()
|
||||||
.input(name)
|
.into_node();
|
||||||
.tip("")
|
StyledField::build()
|
||||||
.build()
|
.label("Url")
|
||||||
.into_node()
|
.input(url)
|
||||||
}
|
.tip("")
|
||||||
|
.build()
|
||||||
/// Build project url input with styled field wrapper
|
.into_node()
|
||||||
fn url_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
}
|
||||||
let url = StyledTextarea::build(FieldId::ProjectSettings(ProjectFieldId::Url))
|
|
||||||
.height(39)
|
/// Build project description text area with styled field wrapper
|
||||||
.max_height(39)
|
fn description_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
||||||
.disable_auto_resize()
|
let description = StyledEditor::build(FieldId::ProjectSettings(ProjectFieldId::Description))
|
||||||
.value(page.payload.url.as_ref().cloned().unwrap_or_default())
|
.text(
|
||||||
.build()
|
page.payload
|
||||||
.into_node();
|
.description
|
||||||
StyledField::build()
|
.as_ref()
|
||||||
.label("Url")
|
.cloned()
|
||||||
.input(url)
|
.unwrap_or_default(),
|
||||||
.tip("")
|
)
|
||||||
.build()
|
.update_on(Ev::Change)
|
||||||
.into_node()
|
.mode(page.description_mode.clone())
|
||||||
}
|
.build()
|
||||||
|
.into_node();
|
||||||
/// Build project description text area with styled field wrapper
|
StyledField::build()
|
||||||
fn description_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
.input(description)
|
||||||
let description = StyledEditor::build(FieldId::ProjectSettings(ProjectFieldId::Description))
|
.label("Description")
|
||||||
.text(
|
.tip("Describe the project in as much detail as you'd like.")
|
||||||
page.payload
|
.build()
|
||||||
.description
|
.into_node()
|
||||||
.as_ref()
|
}
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default(),
|
/// Build project category dropdown with styled field wrapper
|
||||||
)
|
fn category_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
||||||
.update_on(Ev::Change)
|
let category = StyledSelect::build(FieldId::ProjectSettings(ProjectFieldId::Category))
|
||||||
.mode(page.description_mode.clone())
|
.opened(page.project_category_state.opened)
|
||||||
.build()
|
.text_filter(page.project_category_state.text_filter.as_str())
|
||||||
.into_node();
|
.valid(true)
|
||||||
StyledField::build()
|
.normal()
|
||||||
.input(description)
|
.options(
|
||||||
.label("Description")
|
ProjectCategory::ordered()
|
||||||
.tip("Describe the project in as much detail as you'd like.")
|
.into_iter()
|
||||||
.build()
|
.map(|c| c.to_child())
|
||||||
.into_node()
|
.collect(),
|
||||||
}
|
)
|
||||||
|
.selected(vec![page
|
||||||
/// Build project category dropdown with styled field wrapper
|
.payload
|
||||||
fn category_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
.category
|
||||||
let category = StyledSelect::build(FieldId::ProjectSettings(ProjectFieldId::Category))
|
.as_ref()
|
||||||
.opened(page.project_category_state.opened)
|
.cloned()
|
||||||
.text_filter(page.project_category_state.text_filter.as_str())
|
.unwrap_or_default()
|
||||||
.valid(true)
|
.to_child()])
|
||||||
.normal()
|
.build()
|
||||||
.options(
|
.into_node();
|
||||||
ProjectCategory::ordered()
|
StyledField::build()
|
||||||
.into_iter()
|
.label("Project Category")
|
||||||
.map(|c| c.to_child())
|
.input(category)
|
||||||
.collect(),
|
.build()
|
||||||
)
|
.into_node()
|
||||||
.selected(vec![page
|
}
|
||||||
.payload
|
|
||||||
.category
|
/// Build draggable columns preview with option to remove and add new columns
|
||||||
.as_ref()
|
fn columns_section(model: &Model, page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
||||||
.cloned()
|
let width = 100f64 / (model.issue_statuses.len() + 1) as f64;
|
||||||
.unwrap_or_default()
|
let column_style = format!("width: calc({width}% - 10px)", width = width);
|
||||||
.to_child()])
|
let mut per_column_issue_count = HashMap::new();
|
||||||
.build()
|
for issue in model.issues.iter() {
|
||||||
.into_node();
|
*per_column_issue_count
|
||||||
StyledField::build()
|
.entry(issue.issue_status_id)
|
||||||
.label("Project Category")
|
.or_insert(0) += 1;
|
||||||
.input(category)
|
}
|
||||||
.build()
|
let columns: Vec<Node<Msg>> = model
|
||||||
.into_node()
|
.issue_statuses
|
||||||
}
|
.iter()
|
||||||
|
.map(|is| column_preview(is, page, &per_column_issue_count, column_style.as_str()))
|
||||||
fn build_page_content(model: &mut Model) {
|
.collect();
|
||||||
let project = match &model.project {
|
|
||||||
Some(project) => project,
|
let columns_section = section![
|
||||||
_ => return,
|
class!["columnsSection"],
|
||||||
};
|
div![
|
||||||
model.page_content = PageContent::ProjectSettings(Box::new(ProjectSettingsPage::new(project)));
|
class!["columns"],
|
||||||
}
|
columns,
|
||||||
|
add_column(page, column_style.as_str())
|
||||||
/// Build draggable columns preview with option to remove and add new columns
|
]
|
||||||
fn columns_section(model: &Model, page: &Box<ProjectSettingsPage>) -> Node<Msg> {
|
];
|
||||||
let width = 100f64 / (model.issue_statuses.len() + 1) as f64;
|
StyledField::build()
|
||||||
let column_style = format!("width: calc({width}% - 10px)", width = width);
|
.add_class("columnsField")
|
||||||
let mut per_column_issue_count = HashMap::new();
|
.input(columns_section)
|
||||||
for issue in model.issues.iter() {
|
.label("Columns")
|
||||||
*per_column_issue_count
|
.tip("Double-click on name to change it.")
|
||||||
.entry(issue.issue_status_id)
|
.build()
|
||||||
.or_insert(0) += 1;
|
.into_node()
|
||||||
}
|
}
|
||||||
let columns: Vec<Node<Msg>> = model
|
|
||||||
.issue_statuses
|
fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
|
||||||
.iter()
|
let on_click = mouse_ev(Ev::Click, move |_| {
|
||||||
.map(|is| column_preview(is, page, &per_column_issue_count, column_style.as_str()))
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
.collect();
|
ProjectPageChange::EditIssueStatusName(Some(0)),
|
||||||
|
))
|
||||||
let columns_section = section![
|
});
|
||||||
class!["columnsSection"],
|
|
||||||
div![
|
if page.edit_column_id == Some(0) {
|
||||||
class!["columns"],
|
let blur = ev("focusout", |_| {
|
||||||
columns,
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
add_column(page, column_style.as_str())
|
ProjectPageChange::EditIssueStatusName(None),
|
||||||
]
|
))
|
||||||
];
|
});
|
||||||
StyledField::build()
|
let on_submit = ev(Ev::Submit, move |ev| {
|
||||||
.add_class("columnsField")
|
ev.prevent_default();
|
||||||
.input(columns_section)
|
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
.label("Columns")
|
ProjectPageChange::SubmitIssueStatusForm,
|
||||||
.tip("Double-click on name to change it.")
|
)))
|
||||||
.build()
|
});
|
||||||
.into_node()
|
|
||||||
}
|
let input = StyledInput::build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
|
||||||
|
.state(&page.name)
|
||||||
fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node<Msg> {
|
.primary()
|
||||||
let on_click = mouse_ev(Ev::Click, move |_| {
|
.auto_focus()
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
.on_input_ev(blur)
|
||||||
ProjectPageChange::EditIssueStatusName(Some(0)),
|
.build()
|
||||||
))
|
.into_node();
|
||||||
});
|
|
||||||
|
div![
|
||||||
if page.edit_column_id == Some(0) {
|
class!["columnPreview"],
|
||||||
let blur = ev("focusout", |_| {
|
div![class!["columnName"], form![on_submit, input]]
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
]
|
||||||
ProjectPageChange::EditIssueStatusName(None),
|
} else {
|
||||||
))
|
let add_column = StyledIcon::build(Icon::Plus).build().into_node();
|
||||||
});
|
div![
|
||||||
let on_submit = ev(Ev::Submit, move |ev| {
|
class!["columnPreview"],
|
||||||
ev.prevent_default();
|
attrs![At::Style => column_style],
|
||||||
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
div![class!["columnName addColumn"], add_column],
|
||||||
ProjectPageChange::SubmitIssueStatusForm,
|
on_click,
|
||||||
)))
|
]
|
||||||
});
|
}
|
||||||
|
}
|
||||||
let input = StyledInput::build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
|
|
||||||
.state(&page.name)
|
fn column_preview(
|
||||||
.primary()
|
is: &IssueStatus,
|
||||||
.auto_focus()
|
page: &ProjectSettingsPage,
|
||||||
.on_input_ev(blur)
|
per_column_issue_count: &HashMap<i32, i32>,
|
||||||
.build()
|
column_style: &str,
|
||||||
.into_node();
|
) -> Node<Msg> {
|
||||||
|
if page.edit_column_id == Some(is.id) {
|
||||||
div![
|
let blur = ev("focusout", |_| {
|
||||||
class!["columnPreview"],
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
div![class!["columnName"], form![on_submit, input]]
|
ProjectPageChange::EditIssueStatusName(None),
|
||||||
]
|
))
|
||||||
} else {
|
});
|
||||||
let add_column = StyledIcon::build(Icon::Plus).build().into_node();
|
let input = StyledInput::build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
|
||||||
div![
|
.state(&page.name)
|
||||||
class!["columnPreview"],
|
.primary()
|
||||||
attrs![At::Style => column_style],
|
.auto_focus()
|
||||||
div![class!["columnName addColumn"], add_column],
|
.on_input_ev(blur)
|
||||||
on_click,
|
.build()
|
||||||
]
|
.into_node();
|
||||||
}
|
|
||||||
}
|
div![class!["columnPreview"], div![class!["columnName"], input]]
|
||||||
|
} else {
|
||||||
fn column_preview(
|
show_column_preview(is, per_column_issue_count, column_style)
|
||||||
is: &IssueStatus,
|
}
|
||||||
page: &ProjectSettingsPage,
|
}
|
||||||
per_column_issue_count: &HashMap<i32, i32>,
|
|
||||||
column_style: &str,
|
fn show_column_preview(
|
||||||
) -> Node<Msg> {
|
is: &IssueStatus,
|
||||||
if page.edit_column_id == Some(is.id) {
|
per_column_issue_count: &HashMap<i32, i32>,
|
||||||
let blur = ev("focusout", |_| {
|
column_style: &str,
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
) -> Node<Msg> {
|
||||||
ProjectPageChange::EditIssueStatusName(None),
|
let id = is.id;
|
||||||
))
|
let drag_started = drag_ev(Ev::DragStart, move |_| {
|
||||||
});
|
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
let input = StyledInput::build(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName))
|
ProjectPageChange::ColumnDragStarted(id),
|
||||||
.state(&page.name)
|
)))
|
||||||
.primary()
|
});
|
||||||
.auto_focus()
|
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
|
||||||
.on_input_ev(blur)
|
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
.build()
|
ProjectPageChange::ColumnDragStopped(id),
|
||||||
.into_node();
|
)))
|
||||||
|
});
|
||||||
div![class!["columnPreview"], div![class!["columnName"], input]]
|
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
||||||
} else {
|
ev.prevent_default();
|
||||||
show_column_preview(is, per_column_issue_count, column_style)
|
ev.stop_propagation();
|
||||||
}
|
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
}
|
ProjectPageChange::ColumnExchangePosition(id),
|
||||||
|
)))
|
||||||
fn show_column_preview(
|
});
|
||||||
is: &IssueStatus,
|
let drag_out = drag_ev(Ev::DragLeave, move |_| {
|
||||||
per_column_issue_count: &HashMap<i32, i32>,
|
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
column_style: &str,
|
ProjectPageChange::ColumnDragLeave(id),
|
||||||
) -> Node<Msg> {
|
)))
|
||||||
let id = is.id;
|
});
|
||||||
let drag_started = drag_ev(Ev::DragStart, move |_| {
|
|
||||||
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
let on_edit = mouse_ev(Ev::Click, move |_| {
|
||||||
ProjectPageChange::ColumnDragStarted(id),
|
Msg::PageChanged(PageChanged::ProjectSettings(
|
||||||
)))
|
ProjectPageChange::EditIssueStatusName(Some(id)),
|
||||||
});
|
))
|
||||||
let drag_stopped = drag_ev(Ev::DragEnd, move |_| {
|
});
|
||||||
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
let issue_count_in_column = per_column_issue_count.get(&id).cloned().unwrap_or_default();
|
||||||
ProjectPageChange::ColumnDragStopped(id),
|
let delete_row = if issue_count_in_column == 0 {
|
||||||
)))
|
let on_delete = mouse_ev(Ev::Click, move |ev| {
|
||||||
});
|
ev.prevent_default();
|
||||||
let drag_over_handler = drag_ev(Ev::DragOver, move |ev| {
|
ev.stop_propagation();
|
||||||
ev.prevent_default();
|
Msg::ModalOpened(Box::new(ModalType::DeleteIssueStatusModal(Box::new(
|
||||||
ev.stop_propagation();
|
DeleteIssueStatusModal::new(id),
|
||||||
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
))))
|
||||||
ProjectPageChange::ColumnExchangePosition(id),
|
});
|
||||||
)))
|
let delete = StyledButton::build()
|
||||||
});
|
.primary()
|
||||||
let drag_out = drag_ev(Ev::DragLeave, move |_| {
|
.add_class("removeColumn")
|
||||||
Some(Msg::PageChanged(PageChanged::ProjectSettings(
|
.icon(Icon::Trash)
|
||||||
ProjectPageChange::ColumnDragLeave(id),
|
.on_click(on_delete)
|
||||||
)))
|
.build()
|
||||||
});
|
.into_node();
|
||||||
|
div![class!["removeColumn"], delete]
|
||||||
let on_edit = mouse_ev(Ev::Click, move |_| {
|
} else {
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
div![
|
||||||
ProjectPageChange::EditIssueStatusName(Some(id)),
|
class!["issueCount"],
|
||||||
))
|
format!("Issues in column: {}", issue_count_in_column)
|
||||||
});
|
]
|
||||||
let issue_count_in_column = per_column_issue_count.get(&id).cloned().unwrap_or_default();
|
};
|
||||||
let delete_row = if issue_count_in_column == 0 {
|
|
||||||
let on_delete = mouse_ev(Ev::Click, move |ev| {
|
div![
|
||||||
ev.prevent_default();
|
class!["columnPreview"],
|
||||||
ev.stop_propagation();
|
attrs![At::Style => column_style, At::Draggable => "true", At::DropZone => "true"],
|
||||||
Msg::ModalOpened(Box::new(ModalType::DeleteIssueStatusModal(Box::new(
|
div![
|
||||||
DeleteIssueStatusModal::new(id),
|
class!["columnName"],
|
||||||
))))
|
span![is.name.as_str()],
|
||||||
});
|
on_edit,
|
||||||
let delete = StyledButton::build()
|
delete_row
|
||||||
.primary()
|
],
|
||||||
.add_class("removeColumn")
|
drag_started,
|
||||||
.icon(Icon::Trash)
|
drag_stopped,
|
||||||
.on_click(on_delete)
|
drag_over_handler,
|
||||||
.build()
|
drag_out,
|
||||||
.into_node();
|
]
|
||||||
div![class!["removeColumn"], delete]
|
}
|
||||||
} else {
|
|
||||||
div![
|
|
||||||
class!["issueCount"],
|
|
||||||
format!("Issues in column: {}", issue_count_in_column)
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
div![
|
|
||||||
class!["columnPreview"],
|
|
||||||
attrs![At::Style => column_style, At::Draggable => "true", At::DropZone => "true"],
|
|
||||||
div![
|
|
||||||
class!["columnName"],
|
|
||||||
span![is.name.as_str()],
|
|
||||||
on_edit,
|
|
||||||
delete_row
|
|
||||||
],
|
|
||||||
drag_started,
|
|
||||||
drag_stopped,
|
|
||||||
drag_over_handler,
|
|
||||||
drag_out,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
//#######################################
|
|
||||||
// Update
|
|
||||||
//#######################################
|
|
||||||
|
|
||||||
pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
|
|
||||||
if model.page != Page::ProjectSettings {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Msg::WebSocketChange(ref change) => match change {
|
|
||||||
WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
|
|
||||||
init_load(model, orders);
|
|
||||||
}
|
|
||||||
WebSocketChanged::WsMsg(WsMsg::ProjectLoaded(..)) => {
|
|
||||||
build_page_content(model);
|
|
||||||
}
|
|
||||||
WebSocketChanged::WsMsg(WsMsg::IssueStatusCreated(_)) => {
|
|
||||||
match &mut model.page_content {
|
|
||||||
PageContent::ProjectSettings(page) if Some(0) == page.edit_column_id => {
|
|
||||||
page.reset();
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
},
|
|
||||||
Msg::ChangePage(Page::ProjectSettings) => {
|
|
||||||
build_page_content(model);
|
|
||||||
if model.user.is_some() {
|
|
||||||
init_load(model, orders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.user.is_none() || model.project.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let page = match &mut model.page_content {
|
|
||||||
PageContent::ProjectSettings(page) => page,
|
|
||||||
_ => return error!("bad content type"),
|
|
||||||
};
|
|
||||||
page.project_category_state.update(&msg, orders);
|
|
||||||
page.time_tracking.update(&msg);
|
|
||||||
page.name.update(&msg);
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Name), text) => {
|
|
||||||
page.payload.name = Some(text);
|
|
||||||
}
|
|
||||||
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Url), text) => {
|
|
||||||
page.payload.url = Some(text);
|
|
||||||
}
|
|
||||||
Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Description), text) => {
|
|
||||||
page.payload.description = Some(text);
|
|
||||||
}
|
|
||||||
Msg::StyledSelectChanged(
|
|
||||||
FieldId::ProjectSettings(ProjectFieldId::Category),
|
|
||||||
StyledSelectChange::Changed(value),
|
|
||||||
) => {
|
|
||||||
let category = value.into();
|
|
||||||
page.payload.category = Some(category);
|
|
||||||
}
|
|
||||||
Msg::ModalChanged(TabChanged(
|
|
||||||
FieldId::ProjectSettings(ProjectFieldId::Description),
|
|
||||||
mode,
|
|
||||||
)) => {
|
|
||||||
page.description_mode = mode;
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
|
||||||
ProjectPageChange::SubmitProjectSettingsForm,
|
|
||||||
)) => {
|
|
||||||
send_ws_msg(
|
|
||||||
WsMsg::ProjectUpdateRequest(UpdateProjectPayload {
|
|
||||||
id: page.payload.id,
|
|
||||||
name: page.payload.name.clone(),
|
|
||||||
url: page.payload.url.clone(),
|
|
||||||
description: page.payload.description.clone(),
|
|
||||||
category: page.payload.category.clone(),
|
|
||||||
time_tracking: Some(page.time_tracking.value.into()),
|
|
||||||
}),
|
|
||||||
model.ws.as_ref(),
|
|
||||||
orders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStarted(
|
|
||||||
issue_status_id,
|
|
||||||
))) => {
|
|
||||||
page.column_drag.drag(issue_status_id);
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragStopped(
|
|
||||||
_issue_status_id,
|
|
||||||
))) => {
|
|
||||||
sync(model, orders);
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDragLeave(
|
|
||||||
_issue_status_id,
|
|
||||||
))) => page.column_drag.clear_last(),
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
|
||||||
ProjectPageChange::ColumnExchangePosition(issue_bellow_id),
|
|
||||||
)) => exchange_position(issue_bellow_id, model),
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::ColumnDropZone(
|
|
||||||
_issue_status_id,
|
|
||||||
))) => {
|
|
||||||
sync(model, orders);
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::EditIssueStatusName(
|
|
||||||
id,
|
|
||||||
))) => {
|
|
||||||
if page.edit_column_id.is_some() && id.is_none() {
|
|
||||||
let old_id = page.edit_column_id.as_ref().cloned();
|
|
||||||
let name = page.name.value.clone();
|
|
||||||
if let Some((id, pos)) = model
|
|
||||||
.issue_statuses
|
|
||||||
.iter()
|
|
||||||
.find(|is| Some(is.id) == old_id)
|
|
||||||
.map(|is| (is.id, is.position))
|
|
||||||
{
|
|
||||||
send_ws_msg(
|
|
||||||
WsMsg::IssueStatusUpdate(id, name.to_string(), pos),
|
|
||||||
model.ws.as_ref(),
|
|
||||||
orders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
page.name.value = model
|
|
||||||
.issue_statuses
|
|
||||||
.iter()
|
|
||||||
.find_map(|is| {
|
|
||||||
if Some(is.id) == id {
|
|
||||||
Some(is.name.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
page.edit_column_id = id;
|
|
||||||
}
|
|
||||||
Msg::PageChanged(PageChanged::ProjectSettings(
|
|
||||||
ProjectPageChange::SubmitIssueStatusForm,
|
|
||||||
)) => {
|
|
||||||
let name = page.name.value.clone();
|
|
||||||
let position = model.issue_statuses.len();
|
|
||||||
let ws_msg = WsMsg::IssueStatusCreate(name, position as i32);
|
|
||||||
send_ws_msg(ws_msg, model.ws.as_ref(), orders);
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|
||||||
enqueue_ws_msg(
|
|
||||||
vec![
|
|
||||||
WsMsg::ProjectRequest,
|
|
||||||
WsMsg::IssueStatusesRequest,
|
|
||||||
WsMsg::ProjectIssuesRequest,
|
|
||||||
],
|
|
||||||
model.ws.as_ref(),
|
|
||||||
orders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exchange_position(bellow_id: IssueStatusId, model: &mut Model) {
|
|
||||||
let page = match &mut model.page_content {
|
|
||||||
PageContent::ProjectSettings(page) => page,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
if page.column_drag.dragged_or_last(bellow_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let dragged_id = match page.column_drag.dragged_id.as_ref().cloned() {
|
|
||||||
Some(id) => id,
|
|
||||||
_ => return error!("Nothing is dragged"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut below = None;
|
|
||||||
let mut dragged = None;
|
|
||||||
let mut issues_statuses = vec![];
|
|
||||||
std::mem::swap(&mut issues_statuses, &mut model.issue_statuses);
|
|
||||||
|
|
||||||
for issue_status in issues_statuses.into_iter() {
|
|
||||||
match issue_status.id {
|
|
||||||
id if id == bellow_id => below = Some(issue_status),
|
|
||||||
id if id == dragged_id => dragged = Some(issue_status),
|
|
||||||
_ => model.issue_statuses.push(issue_status),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut below = match below {
|
|
||||||
Some(below) => below,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let mut dragged = match dragged {
|
|
||||||
Some(issue_status) => issue_status,
|
|
||||||
_ => {
|
|
||||||
model.issue_statuses.push(below);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
std::mem::swap(&mut dragged.position, &mut below.position);
|
|
||||||
|
|
||||||
page.column_drag.mark_dirty(dragged.id);
|
|
||||||
page.column_drag.mark_dirty(below.id);
|
|
||||||
|
|
||||||
model.issue_statuses.push(below);
|
|
||||||
model.issue_statuses.push(dragged);
|
|
||||||
model
|
|
||||||
.issue_statuses
|
|
||||||
.sort_by(|a, b| a.position.cmp(&b.position));
|
|
||||||
page.column_drag.last_id = Some(bellow_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|
||||||
let dirty = match &mut model.page_content {
|
|
||||||
PageContent::ProjectSettings(page) => {
|
|
||||||
let mut old = HashSet::new();
|
|
||||||
std::mem::swap(&mut old, &mut page.column_drag.dirty);
|
|
||||||
old
|
|
||||||
}
|
|
||||||
_ => return error!("bad content type"),
|
|
||||||
};
|
|
||||||
for id in dirty {
|
|
||||||
let IssueStatus { name, position, .. } =
|
|
||||||
match model.issue_statuses.iter().find(|is| is.id == id) {
|
|
||||||
Some(is) => is,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
send_ws_msg(
|
|
||||||
WsMsg::IssueStatusUpdate(id, name.clone(), *position),
|
|
||||||
model.ws.as_ref(),
|
|
||||||
orders,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -12,7 +12,11 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
match msg {
|
match msg {
|
||||||
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(Ok(_)))) => {
|
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(Ok(_)))) => {
|
||||||
enqueue_ws_msg(
|
enqueue_ws_msg(
|
||||||
vec![WsMsg::UserProjectLoad, WsMsg::ProjectsLoad],
|
vec![
|
||||||
|
WsMsg::UserProjectsLoad,
|
||||||
|
WsMsg::ProjectsLoad,
|
||||||
|
WsMsg::MessagesRequest,
|
||||||
|
],
|
||||||
model.ws.as_ref(),
|
model.ws.as_ref(),
|
||||||
orders,
|
orders,
|
||||||
);
|
);
|
||||||
|
@ -97,18 +97,18 @@ pub fn write_auth_token(token: Option<uuid::Uuid>) -> Result<Msg, String> {
|
|||||||
Some(token) => {
|
Some(token) => {
|
||||||
store
|
store
|
||||||
.set_item("authToken", format!("{}", token).as_str())
|
.set_item("authToken", format!("{}", token).as_str())
|
||||||
.map_err(|_e| "Failed to read auth token".to_string())?;
|
.map_err(|e| format!("Failed to read auth token. {:?}", e))?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
store
|
store
|
||||||
.remove_item("authToken")
|
.remove_item("authToken")
|
||||||
.map_err(|_e| "Failed to read auth token".to_string())?;
|
.map_err(|e| format!("Failed to read auth token. {:?}", e))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(match token {
|
Ok(match token {
|
||||||
Some(_) => Msg::AuthTokenStored,
|
Some(_) => Msg::AuthTokenStored,
|
||||||
_ => Msg::AuthTokenErased,
|
None => Msg::AuthTokenErased,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ use crate::model::Model;
|
|||||||
use crate::shared::styled_avatar::StyledAvatar;
|
use crate::shared::styled_avatar::StyledAvatar;
|
||||||
use crate::shared::styled_button::StyledButton;
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_icon::{Icon, StyledIcon};
|
use crate::shared::styled_icon::{Icon, StyledIcon};
|
||||||
use crate::shared::{styled_tooltip, ToNode};
|
use crate::shared::{divider, styled_tooltip, ToNode};
|
||||||
use crate::Msg;
|
use crate::Msg;
|
||||||
|
|
||||||
trait IntoNavItemIcon {
|
trait IntoNavItemIcon {
|
||||||
@ -109,32 +109,34 @@ fn messages_tooltip_popup(model: &Model) -> Node<Msg> {
|
|||||||
let on_click: EventHandler<Msg> = ev(Ev::Click, move |_| {
|
let on_click: EventHandler<Msg> = ev(Ev::Click, move |_| {
|
||||||
Some(Msg::ToggleTooltip(styled_tooltip::Variant::Messages))
|
Some(Msg::ToggleTooltip(styled_tooltip::Variant::Messages))
|
||||||
});
|
});
|
||||||
let messages: Vec<Node<Msg>> = model
|
let mut messages: Vec<Node<Msg>> = vec![];
|
||||||
.messages
|
for (idx, message) in model.messages.iter().enumerate() {
|
||||||
.iter()
|
let Message {
|
||||||
.map(|message| {
|
id: _,
|
||||||
let Message {
|
receiver_id: _,
|
||||||
id: _,
|
sender_id: _,
|
||||||
receiver_id: _,
|
summary,
|
||||||
sender_id: _,
|
description,
|
||||||
summary,
|
message_type,
|
||||||
description,
|
hyper_link,
|
||||||
message_type,
|
created_at: _,
|
||||||
hyper_link,
|
updated_at: _,
|
||||||
created_at: _,
|
} = message;
|
||||||
updated_at: _,
|
|
||||||
} = message;
|
messages.push(div![
|
||||||
div![
|
class!["message"],
|
||||||
class!["message"],
|
attrs![At::Class => format!("{}", message_type)],
|
||||||
attrs![At::Class => format!("{}", message_type)],
|
div![class!["summary"], summary],
|
||||||
div![class!["summary"], summary],
|
div![class!["description"], description],
|
||||||
div![class!["description"], description],
|
div![class!["hyperlink"], hyper_link],
|
||||||
div![class!["hyperlink"], hyper_link],
|
]);
|
||||||
]
|
if idx != model.messages.len() - 1 {
|
||||||
})
|
messages.push(divider());
|
||||||
.collect();
|
}
|
||||||
|
}
|
||||||
let body = div![on_click, class!["messagesList"], messages];
|
let body = div![on_click, class!["messagesList"], messages];
|
||||||
styled_tooltip::StyledTooltip::build()
|
styled_tooltip::StyledTooltip::build()
|
||||||
|
.add_class("messagesPopup")
|
||||||
.visible(model.messages_tooltip_visible)
|
.visible(model.messages_tooltip_visible)
|
||||||
.messages_tooltip()
|
.messages_tooltip()
|
||||||
.add_child(body)
|
.add_child(body)
|
||||||
|
@ -4,7 +4,7 @@ use crate::shared::ToNode;
|
|||||||
use crate::Msg;
|
use crate::Msg;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum Variant {
|
enum Variant {
|
||||||
Primary,
|
Primary,
|
||||||
Success,
|
Success,
|
||||||
Danger,
|
Danger,
|
||||||
@ -39,7 +39,7 @@ pub struct StyledButtonBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StyledButtonBuilder {
|
impl StyledButtonBuilder {
|
||||||
pub fn variant(mut self, value: Variant) -> Self {
|
fn variant(mut self, value: Variant) -> Self {
|
||||||
self.variant = Some(value);
|
self.variant = Some(value);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use seed::EventHandler;
|
use seed::EventHandler;
|
||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
|
|
||||||
use crate::shared::styled_button::{StyledButton, Variant as ButtonVariant};
|
use crate::shared::styled_button::StyledButton;
|
||||||
use crate::shared::styled_modal::StyledModal;
|
use crate::shared::styled_modal::StyledModal;
|
||||||
use crate::shared::ToNode;
|
use crate::shared::ToNode;
|
||||||
use crate::Msg;
|
use crate::Msg;
|
||||||
@ -116,7 +116,7 @@ pub fn render(values: StyledConfirmModal) -> Node<Msg> {
|
|||||||
};
|
};
|
||||||
let cancel_button = StyledButton::build()
|
let cancel_button = StyledButton::build()
|
||||||
.text(cancel_text)
|
.text(cancel_text)
|
||||||
.variant(ButtonVariant::Secondary)
|
.secondary()
|
||||||
.on_click(mouse_ev(Ev::Click, |_| Msg::ModalDropped))
|
.on_click(mouse_ev(Ev::Click, |_| Msg::ModalDropped))
|
||||||
.build()
|
.build()
|
||||||
.into_node();
|
.into_node();
|
||||||
|
@ -167,7 +167,7 @@ impl StyledSelectBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_state(self, state: &StyledSelectState) -> Self {
|
pub fn state(self, state: &StyledSelectState) -> Self {
|
||||||
self.opened(state.opened)
|
self.opened(state.opened)
|
||||||
.text_filter(state.text_filter.as_str())
|
.text_filter(state.text_filter.as_str())
|
||||||
}
|
}
|
||||||
|
@ -269,6 +269,16 @@ impl ToChild for jirs_data::UserRole {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToChild for jirs_data::Project {
|
||||||
|
type Builder = StyledSelectChildBuilder;
|
||||||
|
|
||||||
|
fn to_child(&self) -> Self::Builder {
|
||||||
|
StyledSelectChild::build()
|
||||||
|
.text(self.name.as_str())
|
||||||
|
.value(self.id as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ToChild for u32 {
|
impl ToChild for u32 {
|
||||||
type Builder = StyledSelectChildBuilder;
|
type Builder = StyledSelectChildBuilder;
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ pub fn view(model: &Model) -> Node<Msg> {
|
|||||||
.name("user_role")
|
.name("user_role")
|
||||||
.valid(true)
|
.valid(true)
|
||||||
.normal()
|
.normal()
|
||||||
.with_state(&page.user_role_state)
|
.state(&page.user_role_state)
|
||||||
.selected(vec![page.user_role.to_child()])
|
.selected(vec![page.user_role.to_child()])
|
||||||
.options(
|
.options(
|
||||||
UserRole::ordered()
|
UserRole::ordered()
|
||||||
|
@ -82,41 +82,28 @@ pub fn update(msg: &WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
if is_non_logged_area() {
|
if is_non_logged_area() {
|
||||||
go_to_board(orders);
|
go_to_board(orders);
|
||||||
}
|
}
|
||||||
|
orders
|
||||||
|
.skip()
|
||||||
|
.send_msg(Msg::UserChanged(model.user.as_ref().cloned()));
|
||||||
}
|
}
|
||||||
WsMsg::AuthorizeExpired => {
|
WsMsg::AuthorizeExpired => {
|
||||||
|
use seed::*;
|
||||||
|
|
||||||
|
log!("Received token expired");
|
||||||
if let Ok(msg) = write_auth_token(None) {
|
if let Ok(msg) = write_auth_token(None) {
|
||||||
orders.skip().send_msg(msg);
|
orders.skip().send_msg(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// project
|
// project
|
||||||
WsMsg::ProjectLoaded(project) => {
|
|
||||||
model.project = Some(project.clone());
|
|
||||||
}
|
|
||||||
WsMsg::ProjectsLoaded(v) => {
|
WsMsg::ProjectsLoaded(v) => {
|
||||||
model.projects = v.clone();
|
model.projects = v.clone();
|
||||||
if !model.projects.is_empty() {
|
init_current_project(model, orders);
|
||||||
model.project = model.current_user_project.as_ref().and_then(|up| {
|
|
||||||
model
|
|
||||||
.projects
|
|
||||||
.iter()
|
|
||||||
.find(|p| p.id == up.project_id)
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// user projects
|
// user projects
|
||||||
WsMsg::UserProjectLoaded(v) => {
|
WsMsg::UserProjectsLoaded(v) => {
|
||||||
model.user_projects = v.clone();
|
model.user_projects = v.clone();
|
||||||
model.current_user_project = v.iter().find(|up| up.is_current).cloned();
|
model.current_user_project = v.iter().find(|up| up.is_current).cloned();
|
||||||
if !model.projects.is_empty() {
|
init_current_project(model, orders);
|
||||||
model.project = model.current_user_project.as_ref().and_then(|up| {
|
|
||||||
model
|
|
||||||
.projects
|
|
||||||
.iter()
|
|
||||||
.find(|p| p.id == up.project_id)
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// issues
|
// issues
|
||||||
@ -215,11 +202,31 @@ pub fn update(msg: &WsMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// messages
|
||||||
|
WsMsg::MessagesResponse(v) => {
|
||||||
|
model.messages = v.clone();
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
orders.render();
|
orders.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_current_project(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
if model.projects.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
model.project = model.current_user_project.as_ref().and_then(|up| {
|
||||||
|
model
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == up.project_id)
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
orders
|
||||||
|
.skip()
|
||||||
|
.send_msg(Msg::ProjectChanged(model.project.as_ref().cloned()));
|
||||||
|
}
|
||||||
|
|
||||||
fn is_non_logged_area() -> bool {
|
fn is_non_logged_area() -> bool {
|
||||||
let pathname = seed::document().location().unwrap().pathname().unwrap();
|
let pathname = seed::document().location().unwrap().pathname().unwrap();
|
||||||
match pathname.as_str() {
|
match pathname.as_str() {
|
||||||
|
@ -681,6 +681,7 @@ pub enum UsersFieldId {
|
|||||||
Email,
|
Email,
|
||||||
UserRole,
|
UserRole,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
CurrentProject,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
|
||||||
@ -754,8 +755,6 @@ pub enum WsMsg {
|
|||||||
InvitedUserRemoveSuccess(UserId),
|
InvitedUserRemoveSuccess(UserId),
|
||||||
|
|
||||||
// project page
|
// project page
|
||||||
ProjectRequest,
|
|
||||||
ProjectLoaded(Project),
|
|
||||||
ProjectsLoad,
|
ProjectsLoad,
|
||||||
ProjectsLoaded(Vec<Project>),
|
ProjectsLoaded(Vec<Project>),
|
||||||
|
|
||||||
@ -797,8 +796,8 @@ pub enum WsMsg {
|
|||||||
ProfileUpdated,
|
ProfileUpdated,
|
||||||
|
|
||||||
// user projects
|
// user projects
|
||||||
UserProjectLoad,
|
UserProjectsLoad,
|
||||||
UserProjectLoaded(Vec<UserProject>),
|
UserProjectsLoaded(Vec<UserProject>),
|
||||||
UserProjectSetCurrent(UserProjectId),
|
UserProjectSetCurrent(UserProjectId),
|
||||||
UserProjectCurrentChanged(UserProject),
|
UserProjectCurrentChanged(UserProject),
|
||||||
|
|
||||||
|
@ -160,5 +160,33 @@ insert into comments (user_id, issue_id, body) values (
|
|||||||
2, 3, 'Praesent et orci ut metus interdum sollicitudin.'
|
2, 3, 'Praesent et orci ut metus interdum sollicitudin.'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
'received_invitation',
|
||||||
|
'assigned_to_issue',
|
||||||
|
'mention'
|
||||||
|
*/
|
||||||
|
INSERT INTO messages (receiver_id, sender_id, summary, description, message_type, hyper_link, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
1, 1,
|
||||||
|
'Foo',
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec elit ligula, tempor non nunc eget, maximus elementum enim. Nam ut elit nibh. Nunc aliquet lectus mi, venenatis porttitor turpis rhoncus a. Aliquam tempus, eros lobortis fermentum dapibus, felis sapien tristique mi, ut porta odio lectus quis nisi. Etiam laoreet quis quam vitae iaculis. Vivamus sagittis luctus urna, porttitor fermentum ligula aliquam in. Suspendisse scelerisque nunc id congue elementum. Aliquam erat volutpat. Integer pretium mi in quam varius lacinia. Nullam vitae justo eu mauris congue posuere. Morbi leo mi, varius eu nisl nec, laoreet scelerisque nisl. Fusce in nisi in felis varius fermentum et ac odio. Curabitur sit amet suscipit quam.',
|
||||||
|
'received_invitation',
|
||||||
|
'',
|
||||||
|
now(), now()
|
||||||
|
), (
|
||||||
|
1, 2,
|
||||||
|
'Bar',
|
||||||
|
'Suspendisse tincidunt euismod justo, at porttitor dolor fermentum ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse maximus sed ex ut sollicitudin. Etiam volutpat ultricies vehicula. Sed at est in mauris cursus fermentum. Duis et lacus metus. Sed ut egestas ipsum, ac consectetur metus. In felis diam, cursus eu felis non, tincidunt elementum lacus. Etiam et massa odio. Vestibulum ornare felis maximus facilisis semper.',
|
||||||
|
'assigned_to_issue',
|
||||||
|
'/issue/1',
|
||||||
|
now(), now()
|
||||||
|
), (
|
||||||
|
2, 1,
|
||||||
|
'Foz Baz',
|
||||||
|
'Suspendisse quam ligula, @<John Doe> auctor vel diam sit amet, tincidunt venenatis justo. Vestibulum tincidunt mauris et est iaculis, vel consequat turpis porta. Integer eu urna quis diam pharetra lobortis vel nec lacus. Donec ac mollis risus. Morbi pellentesque pulvinar libero, sit amet finibus risus fermentum ac. Vivamus imperdiet mi congue ligula luctus condimentum. Duis arcu turpis, dignissim quis purus eget, dignissim elementum risus. Donec mattis rhoncus lorem quis blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dignissim tellus eu cursus finibus. Ut pellentesque mi at eros maximus, eu tempor est sodales. Mauris vel feugiat ligula. Integer quis interdum velit, at iaculis arcu. Duis leo sapien, egestas eget erat id, fringilla pulvinar nulla. Nam sollicitudin ullamcorper finibus.',
|
||||||
|
'mention',
|
||||||
|
'',
|
||||||
|
now(), now()
|
||||||
|
);
|
||||||
|
|
||||||
select * from tokens;
|
select * from tokens;
|
||||||
|
@ -30,12 +30,15 @@ impl Handler<LoadIssue> for DbExecutor {
|
|||||||
.pool
|
.pool
|
||||||
.get()
|
.get()
|
||||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
let record = issues
|
|
||||||
.filter(id.eq(msg.issue_id))
|
let query = issues.filter(id.eq(msg.issue_id)).distinct();
|
||||||
.distinct()
|
debug!(
|
||||||
|
"{}",
|
||||||
|
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
|
||||||
|
);
|
||||||
|
query
|
||||||
.first::<Issue>(conn)
|
.first::<Issue>(conn)
|
||||||
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))?;
|
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))
|
||||||
Ok(record)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
35
jirs-server/src/db/messages.rs
Normal file
35
jirs-server/src/db/messages.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use crate::db::DbExecutor;
|
||||||
|
use crate::errors::ServiceErrors;
|
||||||
|
use actix::Handler;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use jirs_data::{Message, UserId};
|
||||||
|
|
||||||
|
pub struct LoadMessages {
|
||||||
|
pub user_id: UserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl actix::Message for LoadMessages {
|
||||||
|
type Result = Result<Vec<Message>, ServiceErrors>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler<LoadMessages> for DbExecutor {
|
||||||
|
type Result = Result<Vec<Message>, ServiceErrors>;
|
||||||
|
|
||||||
|
fn handle(&mut self, msg: LoadMessages, _ctx: &mut Self::Context) -> Self::Result {
|
||||||
|
use crate::schema::messages::dsl::*;
|
||||||
|
|
||||||
|
let conn = &self
|
||||||
|
.pool
|
||||||
|
.get()
|
||||||
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
|
|
||||||
|
let query = messages.filter(receiver_id.eq(msg.user_id));
|
||||||
|
debug!(
|
||||||
|
"{}",
|
||||||
|
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
|
||||||
|
);
|
||||||
|
query
|
||||||
|
.load(conn)
|
||||||
|
.map_err(|_| ServiceErrors::DatabaseQueryFailed("load user messages".to_string()))
|
||||||
|
}
|
||||||
|
}
|
@ -1,32 +1,23 @@
|
|||||||
use std::fs::*;
|
use std::fs::*;
|
||||||
|
|
||||||
use actix::{Actor, SyncContext};
|
use actix::{Actor, SyncContext};
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::r2d2::{self, ConnectionManager};
|
use diesel::r2d2::{self, ConnectionManager};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
use crate::db::dev::VerboseConnection;
|
|
||||||
|
|
||||||
pub mod authorize_user;
|
pub mod authorize_user;
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod invitations;
|
pub mod invitations;
|
||||||
pub mod issue_assignees;
|
pub mod issue_assignees;
|
||||||
pub mod issue_statuses;
|
pub mod issue_statuses;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod messages;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
pub mod user_projects;
|
pub mod user_projects;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
pub type DbPool = r2d2::Pool<ConnectionManager<dev::VerboseConnection>>;
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
pub type DbPooledConn = r2d2::PooledConnection<ConnectionManager<dev::VerboseConnection>>;
|
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
|
pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
pub type DbPooledConn = r2d2::PooledConnection<ConnectionManager<PgConnection>>;
|
pub type DbPooledConn = r2d2::PooledConnection<ConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
pub struct DbExecutor {
|
pub struct DbExecutor {
|
||||||
@ -51,11 +42,7 @@ pub fn build_pool() -> DbPool {
|
|||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
let config = Configuration::read();
|
let config = Configuration::read();
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
let manager = ConnectionManager::<PgConnection>::new(config.database_url.clone());
|
let manager = ConnectionManager::<PgConnection>::new(config.database_url.clone());
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let manager: ConnectionManager<VerboseConnection> =
|
|
||||||
ConnectionManager::<dev::VerboseConnection>::new(config.database_url.as_str());
|
|
||||||
r2d2::Pool::builder()
|
r2d2::Pool::builder()
|
||||||
.build(manager)
|
.build(manager)
|
||||||
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
|
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
|
||||||
@ -67,82 +54,6 @@ pub trait SyncQuery {
|
|||||||
fn handle(&self, pool: &DbPool) -> Self::Result;
|
fn handle(&self, pool: &DbPool) -> Self::Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
pub mod dev {
|
|
||||||
use std::ops::Deref;
|
|
||||||
|
|
||||||
use diesel::connection::{AnsiTransactionManager, SimpleConnection};
|
|
||||||
use diesel::debug_query;
|
|
||||||
use diesel::deserialize::QueryableByName;
|
|
||||||
use diesel::query_builder::{AsQuery, QueryFragment, QueryId};
|
|
||||||
use diesel::sql_types::HasSqlType;
|
|
||||||
use diesel::{Connection, ConnectionResult, PgConnection, QueryResult, Queryable};
|
|
||||||
|
|
||||||
pub struct VerboseConnection {
|
|
||||||
inner: PgConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for VerboseConnection {
|
|
||||||
type Target = PgConnection;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SimpleConnection for VerboseConnection {
|
|
||||||
fn batch_execute(&self, query: &str) -> QueryResult<()> {
|
|
||||||
self.inner.batch_execute(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Connection for VerboseConnection {
|
|
||||||
type Backend = diesel::pg::Pg;
|
|
||||||
type TransactionManager = AnsiTransactionManager;
|
|
||||||
|
|
||||||
fn establish(database_url: &str) -> ConnectionResult<Self> {
|
|
||||||
PgConnection::establish(database_url).map(|inner| Self { inner })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute(&self, query: &str) -> QueryResult<usize> {
|
|
||||||
self.inner.execute(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_by_index<T, U>(&self, source: T) -> QueryResult<Vec<U>>
|
|
||||||
where
|
|
||||||
T: AsQuery,
|
|
||||||
T::Query: QueryFragment<Self::Backend> + QueryId,
|
|
||||||
Self::Backend: HasSqlType<T::SqlType>,
|
|
||||||
U: Queryable<T::SqlType, Self::Backend>,
|
|
||||||
{
|
|
||||||
self.inner.query_by_index(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_by_name<T, U>(&self, source: &T) -> QueryResult<Vec<U>>
|
|
||||||
where
|
|
||||||
T: QueryFragment<Self::Backend> + QueryId,
|
|
||||||
U: QueryableByName<Self::Backend>,
|
|
||||||
{
|
|
||||||
let q = debug_query::<Self::Backend, _>(&source).to_string();
|
|
||||||
debug!("{:?}", q);
|
|
||||||
self.inner.query_by_name(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute_returning_count<T>(&self, source: &T) -> QueryResult<usize>
|
|
||||||
where
|
|
||||||
T: QueryFragment<Self::Backend> + QueryId,
|
|
||||||
{
|
|
||||||
let q = debug_query::<Self::Backend, _>(&source).to_string();
|
|
||||||
debug!("{:?}", q);
|
|
||||||
self.inner.execute_returning_count(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transaction_manager(&self) -> &Self::TransactionManager {
|
|
||||||
self.inner.transaction_manager()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Configuration {
|
pub struct Configuration {
|
||||||
pub concurrency: usize,
|
pub concurrency: usize,
|
||||||
|
@ -147,6 +147,7 @@ impl Handler<LoadProjects> for DbExecutor {
|
|||||||
let query = projects
|
let query = projects
|
||||||
.inner_join(user_projects.on(project_id.eq(id)))
|
.inner_join(user_projects.on(project_id.eq(id)))
|
||||||
.filter(user_id.eq(msg.user_id))
|
.filter(user_id.eq(msg.user_id))
|
||||||
|
.distinct_on(id)
|
||||||
.select(all_columns);
|
.select(all_columns);
|
||||||
debug!("{}", diesel::debug_query::<diesel::pg::Pg, _>(&query));
|
debug!("{}", diesel::debug_query::<diesel::pg::Pg, _>(&query));
|
||||||
query
|
query
|
||||||
|
@ -6,6 +6,7 @@ use jirs_data::{ProjectId, UserId, UserProject, UserProjectId, UserRole};
|
|||||||
|
|
||||||
use crate::db::DbExecutor;
|
use crate::db::DbExecutor;
|
||||||
use crate::errors::ServiceErrors;
|
use crate::errors::ServiceErrors;
|
||||||
|
use diesel::connection::TransactionManager;
|
||||||
|
|
||||||
pub struct CurrentUserProject {
|
pub struct CurrentUserProject {
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
@ -81,11 +82,20 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
|
|||||||
.get()
|
.get()
|
||||||
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
|
|
||||||
|
let tm = conn.transaction_manager();
|
||||||
|
|
||||||
|
tm.begin_transaction(conn)
|
||||||
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
|
|
||||||
let query = user_projects.filter(id.eq(msg.id).and(user_id.eq(msg.user_id)));
|
let query = user_projects.filter(id.eq(msg.id).and(user_id.eq(msg.user_id)));
|
||||||
debug!("{}", diesel::debug_query::<Pg, _>(&query));
|
debug!("{}", diesel::debug_query::<Pg, _>(&query));
|
||||||
let mut user_project: UserProject = query
|
let mut user_project: UserProject =
|
||||||
.first(conn)
|
query
|
||||||
.map_err(|_e| ServiceErrors::RecordNotFound(format!("user project {}", msg.user_id)))?;
|
.first(conn)
|
||||||
|
.map_err(|_e| match tm.rollback_transaction(conn) {
|
||||||
|
Err(_) => ServiceErrors::DatabaseConnectionLost,
|
||||||
|
_ => ServiceErrors::RecordNotFound(format!("user project {}", msg.user_id)),
|
||||||
|
})?;
|
||||||
|
|
||||||
let query = diesel::update(user_projects)
|
let query = diesel::update(user_projects)
|
||||||
.set(is_current.eq(false))
|
.set(is_current.eq(false))
|
||||||
@ -94,7 +104,13 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
|
|||||||
query
|
query
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|_e| ServiceErrors::RecordNotFound(format!("user project {}", msg.user_id)))?;
|
.map_err(|_e| match tm.rollback_transaction(conn) {
|
||||||
|
Err(_) => ServiceErrors::DatabaseConnectionLost,
|
||||||
|
_ => ServiceErrors::DatabaseQueryFailed(format!(
|
||||||
|
"setting current flag to false while updating current project {}",
|
||||||
|
msg.user_id
|
||||||
|
)),
|
||||||
|
})?;
|
||||||
|
|
||||||
let query = diesel::update(user_projects)
|
let query = diesel::update(user_projects)
|
||||||
.set(is_current.eq(true))
|
.set(is_current.eq(true))
|
||||||
@ -103,7 +119,16 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
|
|||||||
query
|
query
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|_e| ServiceErrors::RecordNotFound(format!("user project {}", msg.user_id)))?;
|
.map_err(|_e| match tm.rollback_transaction(conn) {
|
||||||
|
Err(_) => ServiceErrors::DatabaseConnectionLost,
|
||||||
|
_ => ServiceErrors::DatabaseQueryFailed(format!(
|
||||||
|
"set current flag on project while updating current project {}",
|
||||||
|
msg.user_id
|
||||||
|
)),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tm.commit_transaction(conn)
|
||||||
|
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
|
||||||
|
|
||||||
user_project.is_current = true;
|
user_project.is_current = true;
|
||||||
Ok(user_project)
|
Ok(user_project)
|
||||||
|
24
jirs-server/src/ws/messages.rs
Normal file
24
jirs-server/src/ws/messages.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
use crate::ws::{WebSocketActor, WsHandler, WsResult};
|
||||||
|
use futures::executor::block_on;
|
||||||
|
|
||||||
|
use crate::db::messages;
|
||||||
|
use jirs_data::WsMsg;
|
||||||
|
|
||||||
|
pub struct LoadMessages;
|
||||||
|
|
||||||
|
impl WsHandler<LoadMessages> for WebSocketActor {
|
||||||
|
fn handle_msg(&mut self, _msg: LoadMessages, _ctx: &mut Self::Context) -> WsResult {
|
||||||
|
let user_id = self.require_user()?.id;
|
||||||
|
match block_on(self.db.send(messages::LoadMessages { user_id })) {
|
||||||
|
Ok(Ok(v)) => Ok(Some(WsMsg::MessagesResponse(v))),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ use crate::ws::comments::*;
|
|||||||
use crate::ws::invitations::*;
|
use crate::ws::invitations::*;
|
||||||
use crate::ws::issue_statuses::*;
|
use crate::ws::issue_statuses::*;
|
||||||
use crate::ws::issues::*;
|
use crate::ws::issues::*;
|
||||||
|
use crate::ws::messages::*;
|
||||||
use crate::ws::projects::*;
|
use crate::ws::projects::*;
|
||||||
use crate::ws::user_projects::LoadUserProjects;
|
use crate::ws::user_projects::LoadUserProjects;
|
||||||
use crate::ws::users::*;
|
use crate::ws::users::*;
|
||||||
@ -28,6 +29,7 @@ pub mod comments;
|
|||||||
pub mod invitations;
|
pub mod invitations;
|
||||||
pub mod issue_statuses;
|
pub mod issue_statuses;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod messages;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod user_projects;
|
pub mod user_projects;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
@ -121,12 +123,11 @@ impl WebSocketActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// projects
|
// projects
|
||||||
WsMsg::ProjectRequest => self.handle_msg(CurrentProject, ctx)?,
|
|
||||||
WsMsg::ProjectsLoad => self.handle_msg(LoadProjects, ctx)?,
|
WsMsg::ProjectsLoad => self.handle_msg(LoadProjects, ctx)?,
|
||||||
WsMsg::ProjectUpdateRequest(payload) => self.handle_msg(payload, ctx)?,
|
WsMsg::ProjectUpdateRequest(payload) => self.handle_msg(payload, ctx)?,
|
||||||
|
|
||||||
// user projects
|
// user projects
|
||||||
WsMsg::UserProjectLoad => self.handle_msg(LoadUserProjects, ctx)?,
|
WsMsg::UserProjectsLoad => self.handle_msg(LoadUserProjects, ctx)?,
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
WsMsg::AuthorizeRequest(uuid) => {
|
WsMsg::AuthorizeRequest(uuid) => {
|
||||||
@ -180,6 +181,9 @@ impl WebSocketActor {
|
|||||||
self.handle_msg(ProfileUpdate { email, name }, ctx)?
|
self.handle_msg(ProfileUpdate { email, name }, ctx)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messages
|
||||||
|
WsMsg::MessagesRequest => self.handle_msg(LoadMessages, ctx)?,
|
||||||
|
|
||||||
// else fail
|
// else fail
|
||||||
_ => {
|
_ => {
|
||||||
error!("No handle for {:?} specified", msg);
|
error!("No handle for {:?} specified", msg);
|
||||||
@ -215,17 +219,20 @@ impl WebSocketActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn require_user(&self) -> Result<&User, WsMsg> {
|
fn require_user(&self) -> Result<&User, WsMsg> {
|
||||||
self.current_user
|
self.current_user.as_ref().map(|u| u).ok_or_else(|| {
|
||||||
.as_ref()
|
let _x = 1;
|
||||||
.map(|u| u)
|
WsMsg::AuthorizeExpired
|
||||||
.ok_or_else(|| WsMsg::AuthorizeExpired)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_user_project(&self) -> Result<&UserProject, WsMsg> {
|
fn require_user_project(&self) -> Result<&UserProject, WsMsg> {
|
||||||
self.current_user_project
|
self.current_user_project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|u| u)
|
.map(|u| u)
|
||||||
.ok_or_else(|| WsMsg::AuthorizeExpired)
|
.ok_or_else(|| {
|
||||||
|
let _x = 1;
|
||||||
|
WsMsg::AuthorizeExpired
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fn require_project(&self) -> Result<&Project, WsMsg> {
|
// fn require_project(&self) -> Result<&Project, WsMsg> {
|
||||||
@ -236,10 +243,17 @@ impl WebSocketActor {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
fn load_user_project(&self) -> Result<UserProject, WsMsg> {
|
fn load_user_project(&self) -> Result<UserProject, WsMsg> {
|
||||||
let user_id = self.require_user().map_err(|_| WsMsg::AuthorizeExpired)?.id;
|
let user_id = self.require_user()?.id;
|
||||||
match block_on(self.db.send(CurrentUserProject { user_id })) {
|
match block_on(self.db.send(CurrentUserProject { user_id })) {
|
||||||
Ok(Ok(user_project)) => Ok(user_project),
|
Ok(Ok(user_project)) => Ok(user_project),
|
||||||
_ => Err(WsMsg::AuthorizeExpired),
|
Ok(Err(e)) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
Err(WsMsg::AuthorizeExpired)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
Err(WsMsg::AuthorizeExpired)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,7 +261,14 @@ impl WebSocketActor {
|
|||||||
let project_id = self.require_user_project()?.project_id;
|
let project_id = self.require_user_project()?.project_id;
|
||||||
match block_on(self.db.send(LoadCurrentProject { project_id })) {
|
match block_on(self.db.send(LoadCurrentProject { project_id })) {
|
||||||
Ok(Ok(project)) => Ok(project),
|
Ok(Ok(project)) => Ok(project),
|
||||||
_ => Err(WsMsg::AuthorizeExpired),
|
Ok(Err(e)) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
Err(WsMsg::AuthorizeExpired)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
Err(WsMsg::AuthorizeExpired)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,44 +1,26 @@
|
|||||||
use futures::executor::block_on;
|
use futures::executor::block_on;
|
||||||
|
|
||||||
use jirs_data::{UpdateProjectPayload, WsMsg};
|
use jirs_data::{UpdateProjectPayload, UserProject, WsMsg};
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::db::projects::LoadCurrentProject;
|
|
||||||
use crate::ws::{WebSocketActor, WsHandler, WsResult};
|
use crate::ws::{WebSocketActor, WsHandler, WsResult};
|
||||||
|
|
||||||
pub struct CurrentProject;
|
|
||||||
|
|
||||||
impl WsHandler<CurrentProject> for WebSocketActor {
|
|
||||||
fn handle_msg(&mut self, _msg: CurrentProject, _ctx: &mut Self::Context) -> WsResult {
|
|
||||||
let project_id = self.require_user_project()?.project_id;
|
|
||||||
|
|
||||||
let m = match block_on(self.db.send(LoadCurrentProject { project_id })) {
|
|
||||||
Ok(Ok(project)) => Some(WsMsg::ProjectLoaded(project)),
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("{:?}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("{:?}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsHandler<UpdateProjectPayload> for WebSocketActor {
|
impl WsHandler<UpdateProjectPayload> for WebSocketActor {
|
||||||
fn handle_msg(&mut self, msg: UpdateProjectPayload, _ctx: &mut Self::Context) -> WsResult {
|
fn handle_msg(&mut self, msg: UpdateProjectPayload, _ctx: &mut Self::Context) -> WsResult {
|
||||||
let project_id = self.require_user_project()?.project_id;
|
let UserProject {
|
||||||
let project = match block_on(self.db.send(crate::db::projects::UpdateProject {
|
user_id,
|
||||||
project_id,
|
project_id,
|
||||||
|
..
|
||||||
|
} = self.require_user_project()?;
|
||||||
|
match block_on(self.db.send(crate::db::projects::UpdateProject {
|
||||||
|
project_id: *project_id,
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
url: msg.url,
|
url: msg.url,
|
||||||
description: msg.description,
|
description: msg.description,
|
||||||
category: msg.category,
|
category: msg.category,
|
||||||
time_tracking: msg.time_tracking,
|
time_tracking: msg.time_tracking,
|
||||||
})) {
|
})) {
|
||||||
Ok(Ok(project)) => project,
|
Ok(Ok(_)) => (),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
error!("{:?}", e);
|
error!("{:?}", e);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@ -48,7 +30,21 @@ impl WsHandler<UpdateProjectPayload> for WebSocketActor {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(Some(WsMsg::ProjectLoaded(project)))
|
let projects = match block_on(
|
||||||
|
self.db
|
||||||
|
.send(crate::db::projects::LoadProjects { user_id: *user_id }),
|
||||||
|
) {
|
||||||
|
Ok(Ok(projects)) => projects,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Some(WsMsg::ProjectsLoaded(projects)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ impl WsHandler<LoadUserProjects> for WebSocketActor {
|
|||||||
self.db
|
self.db
|
||||||
.send(db::user_projects::LoadUserProjects { user_id }),
|
.send(db::user_projects::LoadUserProjects { user_id }),
|
||||||
) {
|
) {
|
||||||
Ok(Ok(v)) => Ok(Some(WsMsg::UserProjectLoaded(v))),
|
Ok(Ok(v)) => Ok(Some(WsMsg::UserProjectsLoaded(v))),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
error!("{:?}", e);
|
error!("{:?}", e);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
Loading…
Reference in New Issue
Block a user