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;
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::UserRole => f.write_str("users-userRole"),
UsersFieldId::Avatar => f.write_str("users-avatar"),
UsersFieldId::CurrentProject => f.write_str("users-currentProject"),
},
FieldId::Profile(sub) => match sub {
UsersFieldId::Username => f.write_str("profile-username"),
UsersFieldId::Email => f.write_str("profile-email"),
UsersFieldId::UserRole => f.write_str("profile-userRole"),
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),
ChangePage(model::Page),
UserChanged(Option<User>),
ProjectChanged(Option<Project>),
StyledSelectChanged(FieldId, StyledSelectChange),
InternalFailure(String),
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>> {
use crate::shared::styled_button::Variant as ButtonVariant;
let submit_comment_form = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation();
Msg::SaveComment
@ -573,13 +571,13 @@ fn build_comment_form(form: &CommentForm) -> Vec<Node<Msg>> {
.into_node();
let submit = StyledButton::build()
.variant(ButtonVariant::Primary)
.primary()
.on_click(submit_comment_form)
.text("Save")
.build()
.into_node();
let cancel = StyledButton::build()
.variant(ButtonVariant::Empty)
.empty()
.on_click(close_comment_form)
.text("Cancel")
.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>> {
use crate::shared::styled_button::Variant as ButtonVariant;
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)?;
@ -617,7 +613,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
))
}))
.text("Edit")
.variant(ButtonVariant::Empty)
.empty()
.build()
.into_node();
@ -625,7 +621,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
.add_class("deleteButton")
.on_click(delete_comment_handler)
.text("Delete")
.variant(ButtonVariant::Empty)
.empty()
.build()
.into_node();

View File

@ -99,7 +99,7 @@ pub fn time_tracking_field(
.map(|n| (*n).to_child())
.collect(),
)
.with_state(select_state)
.state(select_state)
.options(
fibonacci_values()
.into_iter()

View File

@ -402,10 +402,11 @@ pub struct ProfilePage {
pub name: StyledInputState,
pub email: StyledInputState,
pub avatar: StyledImageInputState,
pub current_project: StyledSelectState,
}
impl ProfilePage {
pub fn new(user: &User) -> Self {
pub fn new(user: &User, project_ids: Vec<ProjectId>) -> Self {
Self {
name: StyledInputState::new(
FieldId::Profile(UsersFieldId::Username),
@ -419,6 +420,10 @@ impl ProfilePage {
FieldId::Profile(UsersFieldId::Avatar),
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![],
comments: vec![],
issue_statuses: vec![],
messages: vec![Message {
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),
}],
messages: vec![],
user_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

@ -3,149 +3,13 @@ use seed::{prelude::*, *};
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_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::StyledSelectChange;
use crate::shared::{inner_layout, ToNode};
use crate::ws::{enqueue_ws_msg, send_ws_msg};
use crate::{BoardPageChange, EditIssueModalSection, FieldId, Msg, PageChanged, WebSocketChanged};
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![
jirs_data::WsMsg::ProjectRequest,
jirs_data::WsMsg::ProjectIssuesRequest,
jirs_data::WsMsg::ProjectUsersRequest,
jirs_data::WsMsg::IssueStatusesRequest,
],
model.ws.as_ref(),
orders,
);
}
fn build_page_content(model: &mut Model) {
model.page_content = PageContent::Project(Box::new(ProjectPage::default()));
}
use crate::{BoardPageChange, FieldId, Msg, PageChanged};
pub fn view(model: &Model) -> Node<Msg> {
let project_section = vec![
@ -170,11 +34,11 @@ fn breadcrumbs(model: &Model) -> Node<Msg> {
.map(|p| p.name.clone())
.unwrap_or_default();
div![
attrs![At::Class => "breadcrumbsContainer"],
class!["breadcrumbsContainer"],
span!["Projects"],
span![attrs![At::Class => "breadcrumbsDivider"], "/"],
span![class!["breadcrumbsDivider"], "/"],
span![project_name],
span![attrs![At::Class => "breadcrumbsDivider"], "/"],
span![class!["breadcrumbsDivider"], "/"],
span!["Kanban Board"]
]
}
@ -313,6 +177,7 @@ fn project_issue_list(model: &Model, status: &jirs_data::IssueStatus) -> Node<Ms
.iter()
.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())
&& issue_filter_with_only_my(issue, project_page.only_my_filter, &model.user)
&& issue_filter_with_only_recent(issue, ids.as_slice())
@ -353,6 +218,14 @@ fn project_issue_list(model: &Model, status: &jirs_data::IssueStatus) -> Node<Ms
]
}
#[inline]
fn issue_filter_with_avatars(issue: &Issue, user_ids: &Vec<UserId>) -> bool {
if user_ids.is_empty() {
return true;
}
user_ids.contains(&issue.reporter_id) || issue.user_ids.iter().any(|id| user_ids.contains(id))
}
#[inline]
fn issue_filter_status(issue: &Issue, status: &IssueStatus) -> bool {
issue.issue_status_id == status.id

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,15 +1,9 @@
use std::collections::HashSet;
use seed::{prelude::*, *};
use wasm_bindgen::__rt::std::collections::HashMap;
use std::collections::HashMap;
use jirs_data::{
IssueStatus, IssueStatusId, ProjectCategory, TimeTracking, ToVec, UpdateProjectPayload, WsMsg,
};
use jirs_data::{IssueStatus, ProjectCategory, TimeTracking, ToVec};
use crate::model::{
DeleteIssueStatusModal, ModalType, Model, Page, PageContent, ProjectSettingsPage,
};
use crate::model::{DeleteIssueStatusModal, ModalType, Model, PageContent, ProjectSettingsPage};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_checkbox::StyledCheckbox;
use crate::shared::styled_editor::StyledEditor;
@ -17,18 +11,10 @@ use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
use crate::shared::styled_select::StyledSelect;
use crate::shared::styled_textarea::StyledTextarea;
use crate::shared::{inner_layout, ToChild, ToNode};
use crate::ws::{enqueue_ws_msg, send_ws_msg};
use crate::FieldChange::TabChanged;
use crate::{
model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange, WebSocketChanged,
};
//######################################
// VIEW
//######################################
use crate::{model, FieldId, Msg, PageChanged, ProjectFieldId, ProjectPageChange};
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.";
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.";
@ -195,14 +181,6 @@ fn category_field(page: &Box<ProjectSettingsPage>) -> Node<Msg> {
.into_node()
}
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)));
}
/// 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;
@ -378,240 +356,3 @@ fn show_column_preview(
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 {
Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::AuthorizeLoaded(Ok(_)))) => {
enqueue_ws_msg(
vec![WsMsg::UserProjectLoad, WsMsg::ProjectsLoad],
vec![
WsMsg::UserProjectsLoad,
WsMsg::ProjectsLoad,
WsMsg::MessagesRequest,
],
model.ws.as_ref(),
orders,
);

View File

@ -97,18 +97,18 @@ pub fn write_auth_token(token: Option<uuid::Uuid>) -> Result<Msg, String> {
Some(token) => {
store
.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
.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 {
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_button::StyledButton;
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::{styled_tooltip, ToNode};
use crate::shared::{divider, styled_tooltip, ToNode};
use crate::Msg;
trait IntoNavItemIcon {
@ -109,10 +109,8 @@ fn messages_tooltip_popup(model: &Model) -> Node<Msg> {
let on_click: EventHandler<Msg> = ev(Ev::Click, move |_| {
Some(Msg::ToggleTooltip(styled_tooltip::Variant::Messages))
});
let messages: Vec<Node<Msg>> = model
.messages
.iter()
.map(|message| {
let mut messages: Vec<Node<Msg>> = vec![];
for (idx, message) in model.messages.iter().enumerate() {
let Message {
id: _,
receiver_id: _,
@ -124,17 +122,21 @@ fn messages_tooltip_popup(model: &Model) -> Node<Msg> {
created_at: _,
updated_at: _,
} = message;
div![
messages.push(div![
class!["message"],
attrs![At::Class => format!("{}", message_type)],
div![class!["summary"], summary],
div![class!["description"], description],
div![class!["hyperlink"], hyper_link],
]
})
.collect();
]);
if idx != model.messages.len() - 1 {
messages.push(divider());
}
}
let body = div![on_click, class!["messagesList"], messages];
styled_tooltip::StyledTooltip::build()
.add_class("messagesPopup")
.visible(model.messages_tooltip_visible)
.messages_tooltip()
.add_child(body)

View File

@ -4,7 +4,7 @@ use crate::shared::ToNode;
use crate::Msg;
#[allow(dead_code)]
pub enum Variant {
enum Variant {
Primary,
Success,
Danger,
@ -39,7 +39,7 @@ pub struct StyledButtonBuilder {
}
impl StyledButtonBuilder {
pub fn variant(mut self, value: Variant) -> Self {
fn variant(mut self, value: Variant) -> Self {
self.variant = Some(value);
self
}

View File

@ -1,7 +1,7 @@
use seed::EventHandler;
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::ToNode;
use crate::Msg;
@ -116,7 +116,7 @@ pub fn render(values: StyledConfirmModal) -> Node<Msg> {
};
let cancel_button = StyledButton::build()
.text(cancel_text)
.variant(ButtonVariant::Secondary)
.secondary()
.on_click(mouse_ev(Ev::Click, |_| Msg::ModalDropped))
.build()
.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)
.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 {
type Builder = StyledSelectChildBuilder;

View File

@ -48,7 +48,7 @@ pub fn view(model: &Model) -> Node<Msg> {
.name("user_role")
.valid(true)
.normal()
.with_state(&page.user_role_state)
.state(&page.user_role_state)
.selected(vec![page.user_role.to_child()])
.options(
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() {
go_to_board(orders);
}
orders
.skip()
.send_msg(Msg::UserChanged(model.user.as_ref().cloned()));
}
WsMsg::AuthorizeExpired => {
use seed::*;
log!("Received token expired");
if let Ok(msg) = write_auth_token(None) {
orders.skip().send_msg(msg);
}
}
// project
WsMsg::ProjectLoaded(project) => {
model.project = Some(project.clone());
}
WsMsg::ProjectsLoaded(v) => {
model.projects = v.clone();
if !model.projects.is_empty() {
model.project = model.current_user_project.as_ref().and_then(|up| {
model
.projects
.iter()
.find(|p| p.id == up.project_id)
.cloned()
});
}
init_current_project(model, orders);
}
// user projects
WsMsg::UserProjectLoaded(v) => {
WsMsg::UserProjectsLoaded(v) => {
model.user_projects = v.clone();
model.current_user_project = v.iter().find(|up| up.is_current).cloned();
if !model.projects.is_empty() {
model.project = model.current_user_project.as_ref().and_then(|up| {
model
.projects
.iter()
.find(|p| p.id == up.project_id)
.cloned()
});
}
init_current_project(model, orders);
}
// 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();
}
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 {
let pathname = seed::document().location().unwrap().pathname().unwrap();
match pathname.as_str() {

View File

@ -681,6 +681,7 @@ pub enum UsersFieldId {
Email,
UserRole,
Avatar,
CurrentProject,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)]
@ -754,8 +755,6 @@ pub enum WsMsg {
InvitedUserRemoveSuccess(UserId),
// project page
ProjectRequest,
ProjectLoaded(Project),
ProjectsLoad,
ProjectsLoaded(Vec<Project>),
@ -797,8 +796,8 @@ pub enum WsMsg {
ProfileUpdated,
// user projects
UserProjectLoad,
UserProjectLoaded(Vec<UserProject>),
UserProjectsLoad,
UserProjectsLoaded(Vec<UserProject>),
UserProjectSetCurrent(UserProjectId),
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.'
);
/*
'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;

View File

@ -30,12 +30,15 @@ impl Handler<LoadIssue> for DbExecutor {
.pool
.get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
let record = issues
.filter(id.eq(msg.issue_id))
.distinct()
let query = issues.filter(id.eq(msg.issue_id)).distinct();
debug!(
"{}",
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
);
query
.first::<Issue>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))?;
Ok(record)
.map_err(|_| ServiceErrors::RecordNotFound("project issues".to_string()))
}
}

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 actix::{Actor, SyncContext};
#[cfg(not(debug_assertions))]
use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use serde::{Deserialize, Serialize};
#[cfg(debug_assertions)]
use crate::db::dev::VerboseConnection;
pub mod authorize_user;
pub mod comments;
pub mod invitations;
pub mod issue_assignees;
pub mod issue_statuses;
pub mod issues;
pub mod messages;
pub mod projects;
pub mod tokens;
pub mod user_projects;
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>>;
#[cfg(not(debug_assertions))]
pub type DbPooledConn = r2d2::PooledConnection<ConnectionManager<PgConnection>>;
pub struct DbExecutor {
@ -51,11 +42,7 @@ pub fn build_pool() -> DbPool {
dotenv::dotenv().ok();
let config = Configuration::read();
#[cfg(not(debug_assertions))]
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()
.build(manager)
.unwrap_or_else(|e| panic!("Failed to create pool. {}", e))
@ -67,82 +54,6 @@ pub trait SyncQuery {
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)]
pub struct Configuration {
pub concurrency: usize,

View File

@ -147,6 +147,7 @@ impl Handler<LoadProjects> for DbExecutor {
let query = projects
.inner_join(user_projects.on(project_id.eq(id)))
.filter(user_id.eq(msg.user_id))
.distinct_on(id)
.select(all_columns);
debug!("{}", diesel::debug_query::<diesel::pg::Pg, _>(&query));
query

View File

@ -6,6 +6,7 @@ use jirs_data::{ProjectId, UserId, UserProject, UserProjectId, UserRole};
use crate::db::DbExecutor;
use crate::errors::ServiceErrors;
use diesel::connection::TransactionManager;
pub struct CurrentUserProject {
pub user_id: UserId,
@ -81,11 +82,20 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
.get()
.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)));
debug!("{}", diesel::debug_query::<Pg, _>(&query));
let mut user_project: UserProject = query
let mut user_project: UserProject =
query
.first(conn)
.map_err(|_e| ServiceErrors::RecordNotFound(format!("user project {}", msg.user_id)))?;
.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)
.set(is_current.eq(false))
@ -94,7 +104,13 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
query
.execute(conn)
.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)
.set(is_current.eq(true))
@ -103,7 +119,16 @@ impl Handler<ChangeCurrentUserProject> for DbExecutor {
query
.execute(conn)
.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;
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::issue_statuses::*;
use crate::ws::issues::*;
use crate::ws::messages::*;
use crate::ws::projects::*;
use crate::ws::user_projects::LoadUserProjects;
use crate::ws::users::*;
@ -28,6 +29,7 @@ pub mod comments;
pub mod invitations;
pub mod issue_statuses;
pub mod issues;
pub mod messages;
pub mod projects;
pub mod user_projects;
pub mod users;
@ -121,12 +123,11 @@ impl WebSocketActor {
}
// projects
WsMsg::ProjectRequest => self.handle_msg(CurrentProject, ctx)?,
WsMsg::ProjectsLoad => self.handle_msg(LoadProjects, ctx)?,
WsMsg::ProjectUpdateRequest(payload) => self.handle_msg(payload, ctx)?,
// user projects
WsMsg::UserProjectLoad => self.handle_msg(LoadUserProjects, ctx)?,
WsMsg::UserProjectsLoad => self.handle_msg(LoadUserProjects, ctx)?,
// auth
WsMsg::AuthorizeRequest(uuid) => {
@ -180,6 +181,9 @@ impl WebSocketActor {
self.handle_msg(ProfileUpdate { email, name }, ctx)?
}
// messages
WsMsg::MessagesRequest => self.handle_msg(LoadMessages, ctx)?,
// else fail
_ => {
error!("No handle for {:?} specified", msg);
@ -215,17 +219,20 @@ impl WebSocketActor {
}
fn require_user(&self) -> Result<&User, WsMsg> {
self.current_user
.as_ref()
.map(|u| u)
.ok_or_else(|| WsMsg::AuthorizeExpired)
self.current_user.as_ref().map(|u| u).ok_or_else(|| {
let _x = 1;
WsMsg::AuthorizeExpired
})
}
fn require_user_project(&self) -> Result<&UserProject, WsMsg> {
self.current_user_project
.as_ref()
.map(|u| u)
.ok_or_else(|| WsMsg::AuthorizeExpired)
.ok_or_else(|| {
let _x = 1;
WsMsg::AuthorizeExpired
})
}
// fn require_project(&self) -> Result<&Project, WsMsg> {
@ -236,10 +243,17 @@ impl WebSocketActor {
// }
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 })) {
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;
match block_on(self.db.send(LoadCurrentProject { project_id })) {
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 jirs_data::{UpdateProjectPayload, WsMsg};
use jirs_data::{UpdateProjectPayload, UserProject, WsMsg};
use crate::db;
use crate::db::projects::LoadCurrentProject;
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 {
fn handle_msg(&mut self, msg: UpdateProjectPayload, _ctx: &mut Self::Context) -> WsResult {
let project_id = self.require_user_project()?.project_id;
let project = match block_on(self.db.send(crate::db::projects::UpdateProject {
let UserProject {
user_id,
project_id,
..
} = self.require_user_project()?;
match block_on(self.db.send(crate::db::projects::UpdateProject {
project_id: *project_id,
name: msg.name,
url: msg.url,
description: msg.description,
category: msg.category,
time_tracking: msg.time_tracking,
})) {
Ok(Ok(project)) => project,
Ok(Ok(_)) => (),
Ok(Err(e)) => {
error!("{:?}", e);
return Ok(None);
@ -48,7 +30,21 @@ impl WsHandler<UpdateProjectPayload> for WebSocketActor {
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
.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)) => {
error!("{:?}", e);
return Ok(None);