Display messages, load messages, refactor

This commit is contained in:
Adrian Wozniak 2020-05-27 22:04:25 +02:00
parent c5485705a1
commit 5c29a90523
37 changed files with 1632 additions and 1463 deletions

View File

@ -1 +1,3 @@
pub fn main() {} pub fn main() {
// tui::backend::CrosstermBackend::new(stdout());
}

View File

@ -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 {}

View File

@ -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"),
}, },
} }
} }

View File

@ -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),

View File

@ -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();

View File

@ -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()

View File

@ -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![],
} }

View File

@ -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))
}

View File

@ -0,0 +1,5 @@
mod update;
mod view;
pub use update::update;
pub use view::view;

View 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))
}

View 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()
}

View File

@ -0,0 +1,5 @@
mod update;
mod view;
pub use update::*;
pub use view::*;

View 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()));
}

View File

@ -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,],
]
]
]
}

View File

@ -0,0 +1,5 @@
mod update;
mod view;
pub use update::update;
pub use view::view;

View 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)));
}

View File

@ -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 theyre 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 theyre 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 theyre 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 theyre 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,
);
}
}

View File

@ -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,
); );

View File

@ -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,
}) })
} }

View File

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

View File

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

View File

@ -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();

View File

@ -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())
} }

View File

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

View File

@ -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()

View File

@ -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() {

View File

@ -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),

View File

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

View File

@ -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)
} }
} }

View 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()))
}
}

View File

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

View File

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

View File

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

View 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);
}
}
}
}

View File

@ -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)
}
} }
} }
} }

View File

@ -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)))
} }
} }

View File

@ -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);