Most of time tracking modal

This commit is contained in:
Adrian Woźniak 2020-04-14 16:20:05 +02:00
parent 02d2c958b7
commit 91a6445bf6
44 changed files with 848 additions and 371 deletions

View File

@ -13,6 +13,10 @@ crate-type = ["cdylib", "rlib"]
name = "jirs_client" name = "jirs_client"
path = "./src/lib.rs" path = "./src/lib.rs"
[profile.release]
lto = true
opt-level = 's'
[dependencies] [dependencies]
jirs-data = { path = "../jirs-data" } jirs-data = { path = "../jirs-data" }
seed = { version = "*" } seed = { version = "*" }

View File

@ -119,45 +119,6 @@
padding-top: 5px; padding-top: 5px;
} }
.issueDetails > .content > .right > .styledField > .trackingLink {
padding: 4px 4px 2px 0;
border-radius: 4px;
transition: background 0.1s;
cursor: pointer;
user-select: none;
}
.issueDetails > .content > .right > .styledField > .trackingLink:hover {
background: var(--backgroundLight);
}
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget {
display: flex;
justify-content: space-between;
align-items: center;
}
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .watchIcon {
color: var(--textMedium);
}
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right {
width: 90%;
}
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right > .barCounter {
height: 5px;
border-radius: 4px;
background: var(--backgroundMedium);
}
.issueDetails > .content > .right > .styledField > .trackingLink > .trackingWidget > .right > .barCounter > .bar {
height: 5px;
border-radius: 4px;
background: var(--primary);
transition: all 0.1s;
}
/*===================================================*/ /*===================================================*/
/* TOP ACTIONS */ /* TOP ACTIONS */
/*===================================================*/ /*===================================================*/

View File

@ -0,0 +1,13 @@
#projectSettings > .formContainer {
display: flex;
justify-content: center;
}
#projectSettings > .formContainer .styledForm {
max-width: 720px;
width: 100%;
}
#projectSettings > .formContainer .styledForm > .formElement > .actionButton {
margin-top: 30px;
}

View File

@ -1,3 +1,50 @@
.trackingLink {
padding: 4px 4px 2px 0;
border-radius: 4px;
transition: background 0.1s;
cursor: pointer;
user-select: none;
}
.trackingLink:hover {
background: var(--backgroundLight);
}
.trackingWidget {
display: flex;
justify-content: space-between;
align-items: center;
}
.trackingWidget > .watchIcon {
color: var(--textMedium);
}
.trackingWidget > .right {
width: 90%;
}
.trackingWidget > .right > .barCounter {
height: 5px;
border-radius: 4px;
background: var(--backgroundMedium);
}
.trackingWidget > .right > .barCounter > .bar {
height: 5px;
border-radius: 4px;
background: var(--primary);
transition: all 0.1s;
}
.trackingWidget > .right > .values {
display: flex;
justify-content: space-between;
padding-top: 3px;
font-size: 14.5px;
}
/*MODAL*/
.timeTrackingModal { .timeTrackingModal {
padding: 20px 25px 25px; padding: 20px 25px 25px;
} }
@ -6,7 +53,11 @@
padding-bottom: 14px; padding-bottom: 14px;
font-family: var(--font-medium); font-family: var(--font-medium);
font-weight: normal; font-weight: normal;
font-size: 20px font-size: 20px;
}
.timeTrackingModal > .trackingWidget {
width: 100%;
} }
.timeTrackingModal > .inputs { .timeTrackingModal > .inputs {
@ -18,3 +69,8 @@
margin: 0 5px; margin: 0 5px;
width: 50%; width: 50%;
} }
.timeTrackingModal > .actions {
display: flex;
justify-content: flex-end;
}

View File

@ -30,6 +30,7 @@ import("../pkg/index.js").then(module => {
buildWebSocket(); buildWebSocket();
window.send_bin_code = code => queue.push(code); window.send_bin_code = code => queue.push(code);
window.inspectQueue = () => queue;
let wsCheckDelay = 100; let wsCheckDelay = 100;
const flush = () => { const flush = () => {

View File

@ -21,4 +21,5 @@
@import "./css/app.css"; @import "./css/app.css";
@import "./css/issue.css"; @import "./css/issue.css";
@import "./css/project.css"; @import "./css/project.css";
@import "./css/projectSettings.css";
@import "./css/timeTracking.css"; @import "./css/timeTracking.css";

View File

@ -138,6 +138,7 @@ pub enum Msg {
ProjectToggleOnlyMy, ProjectToggleOnlyMy,
ProjectToggleRecentlyUpdated, ProjectToggleRecentlyUpdated,
ProjectClearFilters, ProjectClearFilters,
ProjectSaveChanges,
// dragging // dragging
IssueDragStarted(IssueId), IssueDragStarted(IssueId),
@ -159,7 +160,7 @@ pub enum Msg {
DeleteComment(CommentId), DeleteComment(CommentId),
// modals // modals
ModalOpened(ModalType), ModalOpened(Box<ModalType>),
ModalDropped, ModalDropped,
ModalChanged(FieldChange), ModalChanged(FieldChange),
@ -175,7 +176,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
} }
match &msg { match &msg {
Msg::ChangePage(page) => { Msg::ChangePage(page) => {
model.page = page.clone(); model.page = *page;
} }
Msg::ToggleAboutTooltip => { Msg::ToggleAboutTooltip => {
model.about_tooltip_visible = !model.about_tooltip_visible; model.about_tooltip_visible = !model.about_tooltip_visible;
@ -185,8 +186,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
crate::ws::update(&msg, model, orders); crate::ws::update(&msg, model, orders);
crate::modal::update(&msg, model, orders); crate::modal::update(&msg, model, orders);
match model.page { match model.page {
Page::Project | Page::AddIssue => project::update(msg, model, orders), Page::Project | Page::AddIssue | Page::EditIssue(..) => project::update(msg, model, orders),
Page::EditIssue(_id) => project::update(msg, model, orders),
Page::ProjectSettings => project_settings::update(msg, model, orders), Page::ProjectSettings => project_settings::update(msg, model, orders),
Page::Login => login::update(msg, model, orders), Page::Login => login::update(msg, model, orders),
Page::Register => register::update(msg, model, orders), Page::Register => register::update(msg, model, orders),
@ -242,11 +242,8 @@ pub fn handle_ws_message(value: &wasm_bindgen::JsValue) {
for idx in 0..a.length() { for idx in 0..a.length() {
v.push(a.get_index(idx)); v.push(a.get_index(idx));
} }
match bincode::deserialize(v.as_slice()) { if let Ok(msg) = bincode::deserialize(v.as_slice()) {
Ok(msg) => {
ws::handle(msg); ws::handle(msg);
}
_ => (),
}; };
} }
@ -299,9 +296,8 @@ pub fn render() {
.routes(routes) .routes(routes)
.build_and_start(); .build_and_start();
match crate::shared::read_auth_token() { if let Ok(uuid) = crate::shared::read_auth_token() {
Ok(uuid) => send_ws_msg(WsMsg::AuthorizeRequest(uuid)), send_ws_msg(WsMsg::AuthorizeRequest(uuid));
_ => (),
}; };
let cell_app = std::sync::RwLock::new(app); let cell_app = std::sync::RwLock::new(app);

View File

@ -43,9 +43,9 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde
priority: modal.priority.clone(), priority: modal.priority.clone(),
description: modal.description.clone(), description: modal.description.clone(),
description_text: modal.description_text.clone(), description_text: modal.description_text.clone(),
estimate: modal.estimate.clone(), estimate: modal.estimate,
time_spent: modal.time_spent.clone(), time_spent: modal.time_spent,
time_remaining: modal.time_remaining.clone(), time_remaining: modal.time_remaining,
project_id: modal.project_id.unwrap_or(project_id), project_id: modal.project_id.unwrap_or(project_id),
user_ids: modal.user_ids.clone(), user_ids: modal.user_ids.clone(),
reporter_id: modal.reporter_id.unwrap_or_else(|| user_id), reporter_id: modal.reporter_id.unwrap_or_else(|| user_id),

View File

@ -8,11 +8,12 @@ use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::StyledButton; use crate::shared::styled_button::StyledButton;
use crate::shared::styled_editor::StyledEditor; use crate::shared::styled_editor::StyledEditor;
use crate::shared::styled_field::StyledField; use crate::shared::styled_field::StyledField;
use crate::shared::styled_icon::{Icon, StyledIcon}; use crate::shared::styled_icon::Icon;
use crate::shared::styled_input::StyledInput; use crate::shared::styled_input::StyledInput;
use crate::shared::styled_select::{StyledSelect, StyledSelectChange}; use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
use crate::shared::styled_select_child::ToStyledSelectChild; use crate::shared::styled_select_child::ToStyledSelectChild;
use crate::shared::styled_textarea::StyledTextarea; use crate::shared::styled_textarea::StyledTextarea;
use crate::shared::tracking_widget::tracking_link;
use crate::shared::ToNode; use crate::shared::ToNode;
use crate::{EditIssueModalFieldId, FieldChange, FieldId, Msg}; use crate::{EditIssueModalFieldId, FieldChange, FieldId, Msg};
@ -89,6 +90,14 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
modal.payload.description_text = Some(value.clone()); modal.payload.description_text = Some(value.clone());
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
} }
Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeSpend), value) => {
modal.payload.time_spent = value.parse::<i32>().ok();
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
}
Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeRemaining), value) => {
modal.payload.time_remaining = value.parse::<i32>().ok();
send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone()));
}
Msg::ModalChanged(FieldChange::TabChanged( Msg::ModalChanged(FieldChange::TabChanged(
FieldId::EditIssueModal(EditIssueModalFieldId::Description), FieldId::EditIssueModal(EditIssueModalFieldId::Description),
mode, mode,
@ -197,7 +206,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
.. ..
} = modal; } = modal;
let issue_id = id.clone(); let issue_id = *id;
let click_handler = mouse_ev(Ev::Click, move |_| { let click_handler = mouse_ev(Ev::Click, move |_| {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -220,7 +229,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
}); });
let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped); let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped);
let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| { let delete_confirmation_handler = mouse_ev(Ev::Click, move |_| {
Msg::ModalOpened(ModalType::DeleteIssueConfirm(issue_id)) Msg::ModalOpened(Box::new(ModalType::DeleteIssueConfirm(issue_id)))
}); });
let copy_button = StyledButton::build() let copy_button = StyledButton::build()
@ -261,7 +270,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
.collect(), .collect(),
) )
.selected(vec![{ .selected(vec![{
let id = modal.id.clone(); let id = modal.id;
let issue_type = &payload.issue_type; let issue_type = &payload.issue_type;
issue_type issue_type
.to_select_child() .to_select_child()
@ -424,7 +433,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option<N
let comment_id = comment.id; let comment_id = comment.id;
let delete_comment_handler = mouse_ev(Ev::Click, move |ev| { let delete_comment_handler = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation(); ev.stop_propagation();
Msg::ModalOpened(ModalType::DeleteCommentConfirm(comment_id)) Msg::ModalOpened(Box::new(ModalType::DeleteCommentConfirm(comment_id)))
}); });
let edit_button = StyledButton::build() let edit_button = StyledButton::build()
.add_class("editButton") .add_class("editButton")
@ -582,7 +591,6 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
.estimate .estimate
.as_ref() .as_ref()
.map(|n| n.to_string()) .map(|n| n.to_string())
.clone()
.unwrap_or_default(), .unwrap_or_default(),
) )
.build() .build()
@ -593,7 +601,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let tracking = tracking_widget(model, modal); let tracking = tracking_link(model, modal);
let tracking_field = StyledField::build() let tracking_field = StyledField::build()
.label("TIME TRACKING") .label("TIME TRACKING")
.tip("") .tip("")
@ -611,68 +619,3 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
tracking_field, tracking_field,
] ]
} }
fn tracking_widget(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
id,
payload:
UpdateIssuePayload {
estimate,
time_spent,
time_remaining,
..
},
..
} = modal;
let issue_id = *id;
let icon = StyledIcon::build(Icon::Stopwatch)
.add_class("watchIcon")
.size(32)
.build()
.into_node();
let bar_width = calc_bar_width(estimate, time_spent, time_remaining);
let handler = mouse_ev(Ev::Click, move |_| {
Msg::ModalOpened(ModalType::TimeTracking(issue_id))
});
div![
class!["trackingLink"],
handler,
div![
class!["trackingWidget"],
icon,
div![
class!["right"],
div![
class!["barCounter"],
div![
class!["bar"],
attrs![At::Style => format!("width: {}%", bar_width)]
]
]
]
],
]
}
#[inline]
fn calc_bar_width(
estimate: &Option<i32>,
time_spent: &Option<i32>,
time_remaining: &Option<i32>,
) -> f64 {
match (estimate, time_spent, time_remaining) {
(Some(estimate), Some(spent), _) => {
((*spent as f64 / *estimate as f64) * 100f64).max(100f64)
}
(_, Some(spent), Some(remaining)) => {
(*spent as f64 / (*spent as f64 + *remaining as f64)) * 100f64
}
(None, None, _) => 100f64,
(None, _, _) => 0f64,
_ => 0f64,
}
}

View File

@ -33,18 +33,18 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
} }
Msg::ModalOpened(modal_type) => { Msg::ModalOpened(modal_type) => {
model.modals.push(modal_type.clone()); model.modals.push(modal_type.as_ref().clone());
} }
Msg::WsMsg(jirs_data::WsMsg::ProjectIssuesLoaded(_issues)) => match model.page.clone() { Msg::WsMsg(jirs_data::WsMsg::ProjectIssuesLoaded(_issues)) => match model.page {
Page::EditIssue(issue_id) if model.modals.is_empty() => { Page::EditIssue(issue_id) if model.modals.is_empty() => {
push_edit_modal(&issue_id, model) push_edit_modal(issue_id, model)
} }
_ => (), _ => (),
}, },
Msg::ChangePage(Page::EditIssue(issue_id)) => { Msg::ChangePage(Page::EditIssue(issue_id)) => {
push_edit_modal(issue_id, model); push_edit_modal(*issue_id, model);
} }
Msg::ChangePage(Page::AddIssue) => { Msg::ChangePage(Page::AddIssue) => {
@ -95,14 +95,14 @@ pub fn view(model: &model::Model) -> Node<Msg> {
section![id!["modals"], modals] section![id!["modals"], modals]
} }
fn push_edit_modal(issue_id: &i32, model: &mut Model) { fn push_edit_modal(issue_id: i32, model: &mut Model) {
let modal = { let modal = {
let issue = match find_issue(model, *issue_id) { let issue = match find_issue(model, issue_id) {
Some(issue) => issue, Some(issue) => issue,
_ => return, _ => return,
}; };
ModalType::EditIssue(*issue_id, EditIssueModal::new(issue)) ModalType::EditIssue(issue_id, EditIssueModal::new(issue))
}; };
send_ws_msg(WsMsg::IssueCommentsRequest(issue_id.clone())); send_ws_msg(WsMsg::IssueCommentsRequest(issue_id));
model.modals.push(modal); model.modals.push(modal);
} }

View File

@ -2,27 +2,88 @@ use seed::{prelude::*, *};
use jirs_data::IssueId; use jirs_data::IssueId;
use crate::model::Model; use crate::model::{ModalType, Model};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_input::StyledInput; use crate::shared::styled_input::StyledInput;
use crate::shared::styled_modal::StyledModal; use crate::shared::styled_modal::StyledModal;
use crate::shared::tracking_widget::tracking_widget;
use crate::shared::{find_issue, ToNode}; use crate::shared::{find_issue, ToNode};
use crate::Msg; use crate::{EditIssueModalFieldId, FieldId, Msg, ProjectSettingsFieldId};
pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> { pub fn view(model: &Model, issue_id: IssueId) -> Node<Msg> {
let issue = match find_issue(model, issue_id) { let _issue = match find_issue(model, issue_id) {
Some(issue) => issue, Some(issue) => issue,
_ => return empty![], _ => return empty![],
}; };
let edit_issue_modal = match model.modals.get(0) {
Some(ModalType::EditIssue(_, modal)) => modal,
_ => return empty![],
};
let modal_title = div![class!["modalTitle"], "Time tracking"]; let modal_title = div![class!["modalTitle"], "Time tracking"];
// let time_spent = StyledInput::build() let tracking = tracking_widget(model, edit_issue_modal);
let inputs = div![class!["inputs"], ""]; let time_spent = StyledInput::build(FieldId::EditIssueModal(EditIssueModalFieldId::TimeSpend))
.value(
edit_issue_modal
.payload
.time_spent
.as_ref()
.map(|n| n.to_string())
.unwrap_or_default(),
)
.valid(true)
.build()
.into_node();
let time_spent_field = StyledField::build()
.input(time_spent)
.label("Time spent")
.build()
.into_node();
let time_remaining = StyledInput::build(FieldId::EditIssueModal(
EditIssueModalFieldId::TimeRemaining,
))
.value(
edit_issue_modal
.payload
.time_remaining
.as_ref()
.map(|n| n.to_string())
.unwrap_or_default(),
)
.valid(true)
.build()
.into_node();
let time_remaining_field = StyledField::build()
.input(time_remaining)
.label("Time remaining")
.build()
.into_node();
let inputs = div![
class!["inputs"],
div![class!["inputContainer"], time_spent_field],
div![class!["inputContainer"], time_remaining_field]
];
let close = StyledButton::build()
.text("Done")
.on_click(mouse_ev(Ev::Click, |_| Msg::ModalDropped))
.build()
.into_node();
StyledModal::build() StyledModal::build()
.add_class("timeTrackingModal") .add_class("timeTrackingModal")
.children(vec![modal_title, inputs]) .children(vec![
modal_title,
tracking,
inputs,
div![class!["actions"], close],
])
.width(400)
.build() .build()
.into_node() .into_node()
} }

View File

@ -7,7 +7,9 @@ use jirs_data::*;
use crate::shared::styled_editor::Mode; use crate::shared::styled_editor::Mode;
use crate::shared::styled_select::StyledSelectState; use crate::shared::styled_select::StyledSelectState;
use crate::{AddIssueModalFieldId, EditIssueModalFieldId, FieldId, HOST_URL}; use crate::{
AddIssueModalFieldId, EditIssueModalFieldId, FieldId, ProjectSettingsFieldId, HOST_URL,
};
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
pub enum ModalType { pub enum ModalType {
@ -52,14 +54,14 @@ impl EditIssueModal {
issue_type: issue.issue_type.clone(), issue_type: issue.issue_type.clone(),
status: issue.status.clone(), status: issue.status.clone(),
priority: issue.priority.clone(), priority: issue.priority.clone(),
list_position: issue.list_position.clone(), list_position: issue.list_position,
description: issue.description.clone(), description: issue.description.clone(),
description_text: issue.description_text.clone(), description_text: issue.description_text.clone(),
estimate: issue.estimate.clone(), estimate: issue.estimate,
time_spent: issue.time_spent.clone(), time_spent: issue.time_spent,
time_remaining: issue.time_remaining.clone(), time_remaining: issue.time_remaining,
project_id: issue.project_id.clone(), project_id: issue.project_id,
reporter_id: issue.reporter_id.clone(), reporter_id: issue.reporter_id,
user_ids: issue.user_ids.clone(), user_ids: issue.user_ids.clone(),
}, },
top_type_state: StyledSelectState::new(FieldId::EditIssueModal( top_type_state: StyledSelectState::new(FieldId::EditIssueModal(
@ -151,11 +153,11 @@ pub enum Page {
} }
impl Page { impl Page {
pub fn to_path(&self) -> String { pub fn to_path(self) -> String {
match self { match self {
Page::Project => "/board".to_string(), Page::Project => "/board".to_string(),
Page::EditIssue(id) => format!("/issues/{id}", id = id), Page::EditIssue(id) => format!("/issues/{id}", id = id),
Page::AddIssue => format!("/add-issues"), Page::AddIssue => "/add-issues".to_string(),
Page::ProjectSettings => "/project-settings".to_string(), Page::ProjectSettings => "/project-settings".to_string(),
Page::Login => "/login".to_string(), Page::Login => "/login".to_string(),
Page::Register => "/register".to_string(), Page::Register => "/register".to_string(),
@ -193,7 +195,35 @@ pub struct ProjectPage {
#[derive(Debug)] #[derive(Debug)]
pub struct ProjectSettingsPage { pub struct ProjectSettingsPage {
pub payload: UpdateProjectPayload, pub payload: UpdateProjectPayload,
pub project_type_state: StyledSelectState, pub project_category_state: StyledSelectState,
pub description_mode: crate::shared::styled_editor::Mode,
}
impl ProjectSettingsPage {
pub fn new(project: &Project) -> Self {
use crate::shared::styled_editor::Mode as EditorMode;
let jirs_data::Project {
id,
name,
url,
description,
category,
..
} = project;
Self {
payload: UpdateProjectPayload {
id: *id,
name: Some(name.clone()),
url: Some(url.clone()),
description: Some(description.clone()),
category: Some(category.clone()),
},
description_mode: EditorMode::View,
project_category_state: StyledSelectState::new(FieldId::ProjectSettings(
ProjectSettingsFieldId::Category,
)),
}
}
} }
#[derive(Debug)] #[derive(Debug)]

View File

@ -4,7 +4,7 @@ use seed::{prelude::*, *};
use jirs_data::*; use jirs_data::*;
use crate::api::send_ws_msg; use crate::api::send_ws_msg;
use crate::model::{ModalType, Model, Page, PageContent}; use crate::model::{ModalType, Model, Page, PageContent, ProjectPage};
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};
@ -14,16 +14,23 @@ use crate::shared::{drag_ev, inner_layout, ToNode};
use crate::{EditIssueModalFieldId, FieldId, Msg}; use crate::{EditIssueModalFieldId, FieldId, Msg};
pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) { pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders<Msg>) {
match &model.page {
Page::Project | Page::AddIssue | Page::EditIssue(..) => {
model.page_content = PageContent::Project(ProjectPage::default());
}
_ => return,
}
let project_page = match &mut model.page_content { let project_page = match &mut model.page_content {
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return, _ => return,
}; };
match msg { match msg {
Msg::ChangePage(Page::Project) Msg::WsMsg(WsMsg::AuthorizeLoaded(..))
| Msg::ChangePage(Page::Project)
| Msg::ChangePage(Page::AddIssue) | Msg::ChangePage(Page::AddIssue)
| Msg::ChangePage(Page::EditIssue(..)) | Msg::ChangePage(Page::EditIssue(..)) => {
| Msg::WsMsg(WsMsg::AuthorizeLoaded(Ok(_))) => {
send_ws_msg(jirs_data::WsMsg::ProjectRequest); send_ws_msg(jirs_data::WsMsg::ProjectRequest);
send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest); send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest);
send_ws_msg(jirs_data::WsMsg::ProjectUsersRequest); send_ws_msg(jirs_data::WsMsg::ProjectUsersRequest);
@ -68,19 +75,17 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
Msg::InputChanged(FieldId::TextFilterBoard, text) => { Msg::InputChanged(FieldId::TextFilterBoard, text) => {
project_page.text_filter = text; project_page.text_filter = text;
} }
Msg::ProjectAvatarFilterChanged(user_id, active) => match active { Msg::ProjectAvatarFilterChanged(user_id, active) => {
true => { if active {
project_page.active_avatar_filters = project_page project_page.active_avatar_filters = project_page
.active_avatar_filters .active_avatar_filters
.iter() .iter()
.filter(|id| **id != user_id) .filter_map(|id| if *id != user_id { Some(*id) } else { None })
.map(|id| *id)
.collect(); .collect();
} } else {
false => {
project_page.active_avatar_filters.push(user_id); project_page.active_avatar_filters.push(user_id);
} }
}, }
Msg::ProjectToggleOnlyMy => { Msg::ProjectToggleOnlyMy => {
project_page.only_my_filter = !project_page.only_my_filter; project_page.only_my_filter = !project_page.only_my_filter;
} }
@ -184,16 +189,17 @@ fn project_board_filters(model: &Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let clear_all = match project_page.only_my_filter let clear_all = if project_page.only_my_filter
|| project_page.recently_updated_filter || project_page.recently_updated_filter
|| !project_page.active_avatar_filters.is_empty() || !project_page.active_avatar_filters.is_empty()
{ {
true => seed::button![ seed::button![
id!["clearAllFilters"], id!["clearAllFilters"],
"Clear all", "Clear all",
mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters), mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters),
], ]
false => empty![], } else {
empty![]
}; };
div![ div![
@ -252,11 +258,11 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
PageContent::Project(project_page) => project_page, PageContent::Project(project_page) => project_page,
_ => return empty![], _ => return empty![],
}; };
let ids = if project_page.recently_updated_filter { let ids: Vec<IssueId> = if project_page.recently_updated_filter {
let mut v: Vec<(IssueId, NaiveDateTime)> = model let mut v: Vec<(IssueId, NaiveDateTime)> = model
.issues .issues
.iter() .iter()
.map(|issue| (issue.id, issue.updated_at.clone())) .map(|issue| (issue.id, issue.updated_at))
.collect(); .collect();
v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time)); v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time));
if v.len() > 10 { v[0..10].to_vec() } else { v } if v.len() > 10 { v[0..10].to_vec() } else { v }
@ -273,7 +279,7 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node<Msg
issue_filter_status(issue, &status) issue_filter_status(issue, &status)
&& issue_filter_with_text(issue, project_page.text_filter.as_str()) && 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_my(issue, project_page.only_my_filter, &model.user)
&& issue_filter_with_only_recent(issue, &ids) && issue_filter_with_only_recent(issue, ids.as_slice())
}) })
.map(|issue| project_issue(model, issue)) .map(|issue| project_issue(model, issue))
.collect(); .collect();
@ -324,7 +330,7 @@ fn issue_filter_with_only_my(issue: &Issue, only_my: bool, user: &Option<User>)
} }
#[inline] #[inline]
fn issue_filter_with_only_recent(issue: &Issue, ids: &Vec<IssueId>) -> bool { fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool {
ids.is_empty() || ids.contains(&issue.id) ids.is_empty() || ids.contains(&issue.id)
} }

View File

@ -1,18 +1,92 @@
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use jirs_data::{ProjectCategory, ToVec, WsMsg};
use crate::api::send_ws_msg;
use crate::model::{Model, Page, PageContent, ProjectSettingsPage};
use crate::shared::styled_button::StyledButton;
use crate::shared::styled_editor::StyledEditor; use crate::shared::styled_editor::StyledEditor;
use crate::shared::styled_field::StyledField; use crate::shared::styled_field::StyledField;
use crate::shared::styled_form::StyledForm; use crate::shared::styled_form::StyledForm;
use crate::shared::styled_input::StyledInput; use crate::shared::styled_select::{StyledSelect, StyledSelectChange};
use crate::shared::styled_select_child::ToStyledSelectChild;
use crate::shared::styled_textarea::StyledTextarea;
use crate::shared::{inner_layout, ToNode}; use crate::shared::{inner_layout, ToNode};
use crate::FieldChange::TabChanged;
use crate::{model, FieldId, Msg, ProjectSettingsFieldId}; use crate::{model, FieldId, Msg, ProjectSettingsFieldId};
pub fn update(_msg: Msg, _model: &mut model::Model, _orders: &mut impl Orders<Msg>) {} pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>) {
if model.page != Page::ProjectSettings {
log!("not settings page");
return;
}
match msg {
Msg::WsMsg(WsMsg::AuthorizeLoaded(..)) => {
send_ws_msg(WsMsg::ProjectRequest);
}
Msg::ChangePage(Page::ProjectSettings) => {
send_ws_msg(WsMsg::ProjectRequest);
build_page_content(model);
}
Msg::WsMsg(WsMsg::ProjectLoaded(..)) => {
build_page_content(model);
}
_ => (),
}
let page = match &mut model.page_content {
PageContent::ProjectSettings(page) => page,
_ => return,
};
page.project_category_state.update(&msg, orders);
match msg {
Msg::ProjectSaveChanges => send_ws_msg(WsMsg::ProjectUpdateRequest(page.payload.clone())),
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Name), text) => {
page.payload.name = Some(text);
}
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Url), text) => {
page.payload.url = Some(text);
}
Msg::InputChanged(FieldId::ProjectSettings(ProjectSettingsFieldId::Description), text) => {
page.payload.description = Some(text);
}
Msg::StyledSelectChanged(
FieldId::ProjectSettings(ProjectSettingsFieldId::Category),
StyledSelectChange::Changed(value),
) => {
let category = value.into();
page.payload.category = Some(category);
}
Msg::ModalChanged(TabChanged(
FieldId::ProjectSettings(ProjectSettingsFieldId::Description),
mode,
)) => {
page.description_mode = mode;
}
_ => (),
}
}
fn build_page_content(model: &mut Model) {
let project = match &model.project {
Some(project) => project,
_ => return,
};
model.page_content = PageContent::ProjectSettings(ProjectSettingsPage::new(project));
}
pub fn view(model: &model::Model) -> Node<Msg> { pub fn view(model: &model::Model) -> Node<Msg> {
let name = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name)) let page = match &model.page_content {
.valid(true) PageContent::ProjectSettings(page) => page,
.build() _ => return empty![],
};
let name = StyledTextarea::build()
.value(page.payload.name.as_ref().cloned().unwrap_or_default())
.height(39)
.max_height(39)
.disable_auto_resize()
.build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name))
.into_node(); .into_node();
let name_field = StyledField::build() let name_field = StyledField::build()
.label("Name") .label("Name")
@ -21,9 +95,12 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let url = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url)) let url = StyledTextarea::build()
.valid(true) .height(39)
.build() .max_height(39)
.disable_auto_resize()
.value(page.payload.url.as_ref().cloned().unwrap_or_default())
.build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url))
.into_node(); .into_node();
let url_field = StyledField::build() let url_field = StyledField::build()
.label("Url") .label("Url")
@ -35,8 +112,15 @@ pub fn view(model: &model::Model) -> Node<Msg> {
let description = StyledEditor::build(FieldId::ProjectSettings( let description = StyledEditor::build(FieldId::ProjectSettings(
ProjectSettingsFieldId::Description, ProjectSettingsFieldId::Description,
)) ))
.text("") .text(
page.payload
.description
.as_ref()
.cloned()
.unwrap_or_default(),
)
.update_on(Ev::Change) .update_on(Ev::Change)
.mode(page.description_mode.clone())
.build() .build()
.into_node(); .into_node();
let description_field = StyledField::build() let description_field = StyledField::build()
@ -46,15 +130,50 @@ pub fn view(model: &model::Model) -> Node<Msg> {
.build() .build()
.into_node(); .into_node();
let category = StyledSelect::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Category))
.opened(page.project_category_state.opened)
.text_filter(page.project_category_state.text_filter.as_str())
.valid(true)
.normal()
.options(
ProjectCategory::ordered()
.into_iter()
.map(|c| c.to_select_child())
.collect(),
)
.selected(vec![page
.payload
.category
.as_ref()
.cloned()
.unwrap_or_default()
.to_select_child()])
.build()
.into_node();
let category_field = StyledField::build()
.label("Project Category")
.input(category)
.build()
.into_node();
let save_button = StyledButton::build()
.add_class("actionButton")
.on_click(mouse_ev(Ev::Click, |_| Msg::ProjectSaveChanges))
.text("Save changes")
.build()
.into_node();
let form = StyledForm::build() let form = StyledForm::build()
.heading("Project Details") .heading("Project Details")
.add_field(name_field) .add_field(name_field)
.add_field(url_field) .add_field(url_field)
.add_field(description_field) .add_field(description_field)
.add_field(category_field)
.add_field(save_button)
.build() .build()
.into_node(); .into_node();
let project_section = vec![form]; let project_section = vec![div![class!["formContainer"], form]];
inner_layout(model, "projectSettings", project_section, empty![]) inner_layout(model, "projectSettings", project_section, empty![])
} }

View File

@ -14,7 +14,10 @@ pub fn render(model: &Model) -> Node<Msg> {
div![ div![
attrs![At::Class => "projectTexts";], attrs![At::Class => "projectTexts";],
div![attrs![At::Class => "projectName";], project.name], div![attrs![At::Class => "projectName";], project.name],
div![attrs![At::Class => "projectCategory";], project.category] div![
attrs![At::Class => "projectCategory";],
project.category.to_string()
]
], ],
], ],
_ => li![ _ => li![
@ -50,7 +53,7 @@ pub fn render(model: &Model) -> Node<Msg> {
fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> { fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option<Page>) -> Node<Msg> {
let path = page.map(|ref p| p.to_path()).unwrap_or_default(); let path = page.map(|ref p| p.to_path()).unwrap_or_default();
let mut class_list = vec!["linkItem".to_string(), icon.to_string()]; let mut class_list = vec!["linkItem".to_string(), icon.to_string()];
if let None = page { if page.is_none() {
class_list.push("notAllowed".to_string()); class_list.push("notAllowed".to_string());
}; };
if Some(model.page) == page { if Some(model.page) == page {

View File

@ -21,6 +21,7 @@ pub mod styled_select;
pub mod styled_select_child; pub mod styled_select_child;
pub mod styled_textarea; pub mod styled_textarea;
pub mod styled_tooltip; pub mod styled_tooltip;
pub mod tracking_widget;
pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> { pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> {
model.issues.iter().find(|issue| issue.id == issue_id) model.issues.iter().find(|issue| issue.id == issue_id)

View File

@ -30,9 +30,9 @@ pub struct StyledButtonBuilder {
variant: Option<Variant>, variant: Option<Variant>,
disabled: Option<bool>, disabled: Option<bool>,
active: Option<bool>, active: Option<bool>,
text: Option<Option<String>>, text: Option<String>,
icon: Option<Option<Node<Msg>>>, icon: Option<Node<Msg>>,
on_click: Option<Option<EventHandler<Msg>>>, on_click: Option<EventHandler<Msg>>,
children: Option<Vec<Node<Msg>>>, children: Option<Vec<Node<Msg>>>,
class_list: Vec<String>, class_list: Vec<String>,
} }
@ -77,7 +77,7 @@ impl StyledButtonBuilder {
where where
S: Into<String>, S: Into<String>,
{ {
self.text = Some(Some(value.into())); self.text = Some(value.into());
self self
} }
@ -85,12 +85,12 @@ impl StyledButtonBuilder {
where where
I: ToNode, I: ToNode,
{ {
self.icon = Some(Some(value.into_node())); self.icon = Some(value.into_node());
self self
} }
pub fn on_click(mut self, value: EventHandler<Msg>) -> Self { pub fn on_click(mut self, value: EventHandler<Msg>) -> Self {
self.on_click = Some(Some(value)); self.on_click = Some(value);
self self
} }
@ -112,9 +112,9 @@ impl StyledButtonBuilder {
variant: self.variant.unwrap_or_else(|| Variant::Primary), variant: self.variant.unwrap_or_else(|| Variant::Primary),
disabled: self.disabled.unwrap_or_else(|| false), disabled: self.disabled.unwrap_or_else(|| false),
active: self.active.unwrap_or_else(|| false), active: self.active.unwrap_or_else(|| false),
text: self.text.unwrap_or_default(), text: self.text,
icon: self.icon.unwrap_or_else(|| None), icon: self.icon,
on_click: self.on_click.unwrap_or_else(|| None), on_click: self.on_click,
children: self.children.unwrap_or_default(), children: self.children.unwrap_or_default(),
class_list: self.class_list, class_list: self.class_list,
} }
@ -187,9 +187,10 @@ pub fn render(values: StyledButton) -> Node<Msg> {
At::Class => class_list.join(" "), At::Class => class_list.join(" "),
], ],
handler, handler,
match disabled { if disabled {
true => vec![attrs![At::Disabled => true]], vec![attrs![At::Disabled => true]]
false => vec![], } else {
vec![]
}, },
icon_node, icon_node,
content, content,

View File

@ -38,7 +38,7 @@ pub struct StyledConfirmModalBuilder {
message: Option<String>, message: Option<String>,
confirm_text: Option<String>, confirm_text: Option<String>,
cancel_text: Option<String>, cancel_text: Option<String>,
on_confirm: Option<Option<EventHandler<Msg>>>, on_confirm: Option<EventHandler<Msg>>,
} }
impl StyledConfirmModalBuilder { impl StyledConfirmModalBuilder {
@ -75,7 +75,7 @@ impl StyledConfirmModalBuilder {
} }
pub fn on_confirm(mut self, on_confirm: EventHandler<Msg>) -> Self { pub fn on_confirm(mut self, on_confirm: EventHandler<Msg>) -> Self {
self.on_confirm = Some(Some(on_confirm)); self.on_confirm = Some(on_confirm);
self self
} }
@ -87,7 +87,7 @@ impl StyledConfirmModalBuilder {
.confirm_text .confirm_text
.unwrap_or_else(|| CONFIRM_TEXT.to_string()), .unwrap_or_else(|| CONFIRM_TEXT.to_string()),
cancel_text: self.cancel_text.unwrap_or_else(|| CANCEL_TEXT.to_string()), cancel_text: self.cancel_text.unwrap_or_else(|| CANCEL_TEXT.to_string()),
on_confirm: self.on_confirm.unwrap_or_default(), on_confirm: self.on_confirm,
} }
} }
} }

View File

@ -88,24 +88,24 @@ pub fn render(values: StyledEditor) -> Node<Msg> {
let field_id = id.clone(); let field_id = id.clone();
let on_editor_clicked = mouse_ev(Ev::Click, move |ev| { let on_editor_clicked = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation(); ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::Editor)) Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::Editor))
}); });
let field_id = id.clone(); let field_id = id.clone();
let on_view_clicked = mouse_ev(Ev::Click, move |ev| { let on_view_clicked = mouse_ev(Ev::Click, move |ev| {
ev.stop_propagation(); ev.stop_propagation();
Msg::ModalChanged(FieldChange::TabChanged(field_id.clone(), Mode::View)) Msg::ModalChanged(FieldChange::TabChanged(field_id, Mode::View))
}); });
let editor_id = format!("editor-{}", id.clone()); let editor_id = format!("editor-{}", id);
let view_id = format!("view-{}", id.clone()); let view_id = format!("view-{}", id);
let name = format!("styled-editor-{}", id.clone()); let name = format!("styled-editor-{}", id);
let text_area = StyledTextarea::build() let text_area = StyledTextarea::build()
.height(40) .height(40)
.update_on(update_event) .update_on(update_event)
.value(text.as_str()) .value(text.as_str())
.build(id.clone()) .build(id)
.into_node(); .into_node();
let parsed = comrak::markdown_to_html( let parsed = comrak::markdown_to_html(

View File

@ -54,8 +54,8 @@ pub fn render(values: StyledForm) -> Node<Msg> {
div![ div![
attrs![At::Class => "styledForm"], attrs![At::Class => "styledForm"],
div![ div![
attrs![At::Class => "formElement"], class!["formElement"],
div![attrs![At::Class => "formHeading"], heading], div![class!["formHeading"], heading],
fields fields
], ],
] ]

View File

@ -132,14 +132,14 @@ impl ToNode for Icon {
pub struct StyledIconBuilder { pub struct StyledIconBuilder {
icon: Icon, icon: Icon,
size: Option<Option<i32>>, size: Option<i32>,
class_list: Vec<String>, class_list: Vec<String>,
style_list: Vec<String>, style_list: Vec<String>,
} }
impl StyledIconBuilder { impl StyledIconBuilder {
pub fn size(mut self, size: i32) -> Self { pub fn size(mut self, size: i32) -> Self {
self.size = Some(Some(size)); self.size = Some(size);
self self
} }
@ -162,7 +162,7 @@ impl StyledIconBuilder {
pub fn build(self) -> StyledIcon { pub fn build(self) -> StyledIcon {
StyledIcon { StyledIcon {
icon: self.icon, icon: self.icon,
size: self.size.unwrap_or_default(), size: self.size,
class_list: self.class_list, class_list: self.class_list,
style_list: self.style_list, style_list: self.style_list,
} }

View File

@ -88,26 +88,29 @@ pub fn render(values: StyledInput) -> Node<Msg> {
Some(icon) => StyledIcon::build(icon).build().into_node(), Some(icon) => StyledIcon::build(icon).build().into_node(),
_ => empty![], _ => empty![],
}; };
let field_id = id.clone();
let mut handlers = vec![]; let change_handler = keyboard_ev(Ev::KeyUp, move |event| {
use wasm_bindgen::JsCast;
handlers.push(input_ev(Ev::Change, move |value| { event.stop_propagation();
Msg::InputChanged(id, value) let value = event
})); .target()
.unwrap()
.dyn_ref::<web_sys::HtmlInputElement>()
.unwrap()
.value();
log!("asd");
Msg::InputChanged(field_id, value)
});
div![ div![
attrs!(At::Class => wrapper_class_list.join(" ")), attrs!(At::Class => wrapper_class_list.join(" ")),
icon, icon,
keyboard_ev(Ev::KeyUp, |ev| {
ev.stop_propagation();
Msg::NoOp
}),
seed::input![ seed::input![
attrs![ attrs![
At::Class => input_class_list.join(" "), At::Class => input_class_list.join(" "),
At::Value => value.unwrap_or_default(), At::Value => value.unwrap_or_default(),
], ],
handlers change_handler,
], ],
] ]
} }

View File

@ -112,8 +112,8 @@ impl StyledSelect {
pub struct StyledSelectBuilder { pub struct StyledSelectBuilder {
id: FieldId, id: FieldId,
variant: Option<Variant>, variant: Option<Variant>,
dropdown_width: Option<Option<usize>>, dropdown_width: Option<usize>,
name: Option<Option<String>>, name: Option<String>,
valid: Option<bool>, valid: Option<bool>,
is_multi: Option<bool>, is_multi: Option<bool>,
allow_clear: Option<bool>, allow_clear: Option<bool>,
@ -128,8 +128,8 @@ impl StyledSelectBuilder {
StyledSelect { StyledSelect {
id: self.id, id: self.id,
variant: self.variant.unwrap_or_default(), variant: self.variant.unwrap_or_default(),
dropdown_width: self.dropdown_width.unwrap_or_default(), dropdown_width: self.dropdown_width,
name: self.name.unwrap_or_default(), name: self.name,
valid: self.valid.unwrap_or(true), valid: self.valid.unwrap_or(true),
is_multi: self.is_multi.unwrap_or_default(), is_multi: self.is_multi.unwrap_or_default(),
allow_clear: self.allow_clear.unwrap_or_default(), allow_clear: self.allow_clear.unwrap_or_default(),
@ -141,7 +141,7 @@ impl StyledSelectBuilder {
} }
pub fn dropdown_width(mut self, dropdown_width: usize) -> Self { pub fn dropdown_width(mut self, dropdown_width: usize) -> Self {
self.dropdown_width = Some(Some(dropdown_width)); self.dropdown_width = Some(dropdown_width);
self self
} }
@ -149,7 +149,7 @@ impl StyledSelectBuilder {
where where
S: Into<String>, S: Into<String>,
{ {
self.name = Some(Some(name.into())); self.name = Some(name.into());
self self
} }
@ -224,19 +224,20 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
let dropdown_style = dropdown_width let dropdown_style = dropdown_width
.map(|n| format!("width: {}px;", n)) .map(|n| format!("width: {}px;", n))
.unwrap_or_else(|| format!("width: 100%;")); .unwrap_or_else(|| "width: 100%;".to_string());
let mut select_class = vec!["styledSelect".to_string(), format!("{}", variant)]; let mut select_class = vec!["styledSelect".to_string(), format!("{}", variant)];
if !valid { if !valid {
select_class.push("invalid".to_string()); select_class.push("invalid".to_string());
} }
let chevron_down = match (selected.is_empty() || !is_multi) && variant != Variant::Empty { let chevron_down = if (selected.is_empty() || !is_multi) && variant != Variant::Empty {
true => StyledIcon::build(Icon::ChevronDown) StyledIcon::build(Icon::ChevronDown)
.add_class("chevronIcon") .add_class("chevronIcon")
.build() .build()
.into_node(), .into_node()
_ => empty![], } else {
empty![]
}; };
let children: Vec<Node<Msg>> = options let children: Vec<Node<Msg>> = options
@ -259,8 +260,8 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
}) })
.collect(); .collect();
let text_input = match opened { let text_input = if opened {
true => seed::input![ seed::input![
attrs![ attrs![
At::Name => name.unwrap_or_default(), At::Name => name.unwrap_or_default(),
At::Class => "dropDownInput", At::Class => "dropDownInput",
@ -268,9 +269,10 @@ pub fn render(values: StyledSelect) -> Node<Msg> {
At::Placeholder => "Search" At::Placeholder => "Search"
At::AutoFocus => "true", At::AutoFocus => "true",
], ],
on_text.clone(), on_text,
], ]
_ => empty![], } else {
empty![]
}; };
let clear_icon = match (opened, allow_clear) { let clear_icon = match (opened, allow_clear) {

View File

@ -217,7 +217,7 @@ impl ToStyledSelectChild for jirs_data::IssueStatus {
StyledSelectChild::build() StyledSelectChild::build()
.value(self.clone().into()) .value(self.clone().into())
.add_class(text.clone()) .add_class(text)
.text(text) .text(text)
} }
} }
@ -238,3 +238,14 @@ impl ToStyledSelectChild for jirs_data::IssueType {
.value(self.clone().into()) .value(self.clone().into())
} }
} }
impl ToStyledSelectChild for jirs_data::ProjectCategory {
fn to_select_child(&self) -> StyledSelectChildBuilder {
let name = self.to_string();
StyledSelectChild::build()
.add_class(name.as_str())
.text(name)
.value(self.clone().into())
}
}

View File

@ -12,6 +12,7 @@ pub struct StyledTextarea {
class_list: Vec<String>, class_list: Vec<String>,
update_event: Ev, update_event: Ev,
placeholder: Option<String>, placeholder: Option<String>,
disable_auto_resize: bool,
} }
impl ToNode for StyledTextarea { impl ToNode for StyledTextarea {
@ -35,6 +36,7 @@ pub struct StyledTextareaBuilder {
class_list: Vec<String>, class_list: Vec<String>,
update_event: Option<Ev>, update_event: Option<Ev>,
placeholder: Option<String>, placeholder: Option<String>,
disable_auto_resize: bool,
} }
impl StyledTextareaBuilder { impl StyledTextareaBuilder {
@ -81,6 +83,11 @@ impl StyledTextareaBuilder {
self self
} }
pub fn disable_auto_resize(mut self) -> Self {
self.disable_auto_resize = true;
self
}
#[inline] #[inline]
pub fn build(self, id: FieldId) -> StyledTextarea { pub fn build(self, id: FieldId) -> StyledTextarea {
StyledTextarea { StyledTextarea {
@ -91,6 +98,7 @@ impl StyledTextareaBuilder {
max_height: self.max_height.unwrap_or_default(), max_height: self.max_height.unwrap_or_default(),
update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp), update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp),
placeholder: self.placeholder, placeholder: self.placeholder,
disable_auto_resize: self.disable_auto_resize,
} }
} }
} }
@ -117,10 +125,11 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
mut class_list, mut class_list,
update_event, update_event,
placeholder, placeholder,
disable_auto_resize,
} = values; } = values;
let mut style_list = vec![]; let mut style_list = vec![];
let min_height = get_min_height(value.as_str(), height as f64); let min_height = get_min_height(value.as_str(), height as f64, disable_auto_resize);
if min_height > 0f64 { if min_height > 0f64 {
style_list.push(format!("min-height: {}px", min_height)); style_list.push(format!("min-height: {}px", min_height));
} }
@ -129,8 +138,13 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
style_list.push(format!("max-height: {}px", max_height)); style_list.push(format!("max-height: {}px", max_height));
} }
if disable_auto_resize {
style_list.push("resize: none".to_string());
}
let mut handlers = vec![]; let mut handlers = vec![];
let handler_disable_auto_resize = disable_auto_resize;
let resize_handler = ev(Ev::KeyUp, move |event| { let resize_handler = ev(Ev::KeyUp, move |event| {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -140,7 +154,7 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
}; };
let text_area = target.dyn_ref::<web_sys::HtmlTextAreaElement>().unwrap(); let text_area = target.dyn_ref::<web_sys::HtmlTextAreaElement>().unwrap();
let value: String = text_area.value(); let value: String = text_area.value();
let min_height = get_min_height(value.as_str(), height as f64); let min_height = get_min_height(value.as_str(), height as f64, handler_disable_auto_resize);
text_area text_area
.style() .style()
@ -148,11 +162,9 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
Msg::NoOp Msg::NoOp
}); });
handlers.push(resize_handler); handlers.push(resize_handler);
let text_input_handler = input_ev(update_event.clone(), move |value| { let text_input_handler = input_ev(update_event, move |value| Msg::InputChanged(id, value));
Msg::InputChanged(id, value)
});
handlers.push(text_input_handler); handlers.push(text_input_handler);
handlers.push(keyboard_ev(Ev::KeyUp, |ev| { handlers.push(keyboard_ev(Ev::Input, |ev| {
ev.stop_propagation(); ev.stop_propagation();
Msg::NoOp Msg::NoOp
})); }));
@ -175,9 +187,12 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
] ]
} }
fn get_min_height(value: &str, min_height: f64) -> f64 { fn get_min_height(value: &str, min_height: f64, disable_auto_resize: bool) -> f64 {
if disable_auto_resize {
return min_height;
}
let len = value.lines().count() as f64; let len = value.lines().count() as f64;
if value.chars().last() == Some('\n') {} // if value.chars().last() == Some('\n') {}
let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT; let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT;
if calc_height + ADDITIONAL_HEIGHT < min_height { if calc_height + ADDITIONAL_HEIGHT < min_height {

View File

@ -0,0 +1,89 @@
use seed::{prelude::*, *};
use jirs_data::UpdateIssuePayload;
use crate::model::{EditIssueModal, ModalType, Model};
use crate::shared::styled_icon::{Icon, StyledIcon};
use crate::shared::ToNode;
use crate::Msg;
pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal { id, .. } = modal;
let issue_id = *id;
let handler = mouse_ev(Ev::Click, move |_| {
Msg::ModalOpened(Box::new(ModalType::TimeTracking(issue_id)))
});
div![
class!["trackingLink"],
handler,
tracking_widget(model, modal),
]
}
pub fn tracking_widget(_model: &Model, modal: &EditIssueModal) -> Node<Msg> {
let EditIssueModal {
payload:
UpdateIssuePayload {
estimate,
time_spent,
time_remaining,
..
},
..
} = modal;
let icon = StyledIcon::build(Icon::Stopwatch)
.add_class("watchIcon")
.size(32)
.build()
.into_node();
let bar_width = calc_bar_width(*estimate, *time_spent, *time_remaining);
let spent_text = if let Some(time) = time_spent {
format!("{}h logged", time)
} else {
"No time logged".to_string()
};
let remaining_node: Node<Msg> = match (time_remaining, estimate) {
(Some(n), _) => div![format!("{}h remaining", n)],
(_, Some(n)) => div![format!("{}h estimated", n)],
_ => empty![],
};
div![
class!["trackingWidget"],
icon,
div![
class!["right"],
div![
class!["barCounter"],
div![
class!["bar"],
attrs![At::Style => format!("width: {}%", bar_width)]
]
],
div![class!["values"], div![spent_text], remaining_node,]
]
]
}
#[inline]
fn calc_bar_width(
estimate: Option<i32>,
time_spent: Option<i32>,
time_remaining: Option<i32>,
) -> f64 {
match (estimate, time_spent, time_remaining) {
(Some(estimate), Some(spent), _) => ((spent as f64 / estimate as f64) * 100f64).max(100f64),
(_, Some(spent), Some(remaining)) => {
(spent as f64 / (spent as f64 + remaining as f64)) * 100f64
}
(None, None, _) => 100f64,
(None, _, _) => 0f64,
_ => 0f64,
}
}

View File

@ -150,7 +150,7 @@ pub fn change_status(status: IssueStatus, model: &mut Model) {
model.issues.push(issue); model.issues.push(issue);
return; return;
} }
issue.status = status.clone(); issue.status = status;
issue.list_position = pos + 1; issue.list_position = pos + 1;
model.issues.push(issue); model.issues.push(issue);

View File

@ -26,7 +26,6 @@ pub type TokenId = i32;
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))] #[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
#[cfg_attr(feature = "backend", sql_type = "IssueTypeType")] #[cfg_attr(feature = "backend", sql_type = "IssueTypeType")]
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)] #[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum IssueType { pub enum IssueType {
Task, Task,
Bug, Bug,
@ -248,6 +247,77 @@ impl Into<IssuePriority> for u32 {
} }
} }
#[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))]
#[cfg_attr(feature = "backend", sql_type = "ProjectCategoryType")]
#[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)]
pub enum ProjectCategory {
Software,
Marketing,
Business,
}
impl ToVec for ProjectCategory {
type Item = ProjectCategory;
fn ordered() -> Vec<Self> {
vec![
ProjectCategory::Software,
ProjectCategory::Marketing,
ProjectCategory::Business,
]
}
}
impl FromStr for ProjectCategory {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().trim() {
"software" => Ok(ProjectCategory::Software),
"marketing" => Ok(ProjectCategory::Marketing),
"business" => Ok(ProjectCategory::Business),
_ => Err(format!("Unknown project category {}", s)),
}
}
}
impl Default for ProjectCategory {
fn default() -> Self {
ProjectCategory::Software
}
}
impl std::fmt::Display for ProjectCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectCategory::Software => f.write_str("software"),
ProjectCategory::Marketing => f.write_str("marketing"),
ProjectCategory::Business => f.write_str("business"),
}
}
}
impl Into<u32> for ProjectCategory {
fn into(self) -> u32 {
match self {
ProjectCategory::Software => 0,
ProjectCategory::Marketing => 1,
ProjectCategory::Business => 2,
}
}
}
impl Into<ProjectCategory> for u32 {
fn into(self) -> ProjectCategory {
match self {
0 => ProjectCategory::Software,
1 => ProjectCategory::Marketing,
2 => ProjectCategory::Business,
_ => ProjectCategory::Software,
}
}
}
#[derive(Clone, Serialize, Debug, PartialEq)] #[derive(Clone, Serialize, Debug, PartialEq)]
pub struct ErrorResponse { pub struct ErrorResponse {
pub errors: Vec<String>, pub errors: Vec<String>,
@ -259,7 +329,7 @@ pub struct Project {
pub name: String, pub name: String,
pub url: String, pub url: String,
pub description: String, pub description: String,
pub category: String, pub category: ProjectCategory,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@ -382,12 +452,13 @@ pub struct CreateIssuePayload {
pub reporter_id: UserId, pub reporter_id: UserId,
} }
#[derive(Serialize, Deserialize, Debug, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct UpdateProjectPayload { pub struct UpdateProjectPayload {
pub id: ProjectId,
pub name: Option<String>, pub name: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub category: Option<String>, pub category: Option<ProjectCategory>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@ -407,6 +478,7 @@ pub enum WsMsg {
ProjectIssuesLoaded(Vec<Issue>), ProjectIssuesLoaded(Vec<Issue>),
ProjectUsersRequest, ProjectUsersRequest,
ProjectUsersLoaded(Vec<User>), ProjectUsersLoaded(Vec<User>),
ProjectUpdateRequest(UpdateProjectPayload),
// issue // issue
IssueUpdateRequest(IssueId, UpdateIssuePayload), IssueUpdateRequest(IssueId, UpdateIssuePayload),

View File

@ -2,7 +2,7 @@ use std::io::Write;
use diesel::{deserialize::*, pg::*, serialize::*, *}; use diesel::{deserialize::*, pg::*, serialize::*, *};
use crate::{IssuePriority, IssueStatus, IssueType}; use crate::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
#[derive(SqlType)] #[derive(SqlType)]
#[postgres(type_name = "IssuePriorityType")] #[postgres(type_name = "IssuePriorityType")]
@ -121,3 +121,45 @@ impl ToSql<IssueStatusType, Pg> for IssueStatus {
Ok(IsNull::No) Ok(IsNull::No)
} }
} }
///////////
#[derive(SqlType)]
#[postgres(type_name = "ProjectCategoryType")]
pub struct ProjectCategoryType;
impl diesel::query_builder::QueryId for ProjectCategoryType {
type QueryId = IssueStatus;
}
fn project_category_from_sql(bytes: Option<&[u8]>) -> deserialize::Result<ProjectCategory> {
match not_none!(bytes) {
b"software" => Ok(ProjectCategory::Software),
b"marketing" => Ok(ProjectCategory::Marketing),
b"business" => Ok(ProjectCategory::Business),
_ => Ok(ProjectCategory::Software),
}
}
impl FromSql<ProjectCategoryType, Pg> for ProjectCategory {
fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result<Self> {
project_category_from_sql(bytes)
}
}
impl FromSql<sql_types::Text, Pg> for ProjectCategory {
fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result<Self> {
project_category_from_sql(bytes)
}
}
impl ToSql<ProjectCategoryType, Pg> for ProjectCategory {
fn to_sql<W: Write>(&self, out: &mut Output<W, Pg>) -> serialize::Result {
match *self {
ProjectCategory::Software => out.write_all(b"software")?,
ProjectCategory::Marketing => out.write_all(b"marketing")?,
ProjectCategory::Business => out.write_all(b"business")?,
}
Ok(IsNull::No)
}
}

View File

@ -0,0 +1 @@
ALTER TABLE projects ALTER COLUMN category SET DATA TYPE VARCHAR;

View File

@ -0,0 +1,12 @@
ALTER TABLE projects
ALTER COLUMN category
DROP DEFAULT;
ALTER TABLE projects
ALTER COLUMN category
SET DATA TYPE "ProjectCategoryType"
USING category::text::"ProjectCategoryType";
ALTER TABLE projects
ALTER COLUMN category
SET DEFAULT 'software';

View File

@ -77,11 +77,11 @@ pub struct UpdateIssue {
pub status: Option<IssueStatus>, pub status: Option<IssueStatus>,
pub priority: Option<IssuePriority>, pub priority: Option<IssuePriority>,
pub list_position: Option<i32>, pub list_position: Option<i32>,
pub description: Option<Option<String>>, pub description: Option<String>,
pub description_text: Option<Option<String>>, pub description_text: Option<String>,
pub estimate: Option<Option<i32>>, pub estimate: Option<i32>,
pub time_spent: Option<Option<i32>>, pub time_spent: Option<i32>,
pub time_remaining: Option<Option<i32>>, pub time_remaining: Option<i32>,
pub project_id: Option<i32>, pub project_id: Option<i32>,
pub user_ids: Option<Vec<i32>>, pub user_ids: Option<Vec<i32>>,
pub reporter_id: Option<i32>, pub reporter_id: Option<i32>,

View File

@ -23,8 +23,8 @@ impl Actor for DbExecutor {
type Context = SyncContext<Self>; type Context = SyncContext<Self>;
} }
impl DbExecutor { impl Default for DbExecutor {
pub fn new() -> Self { fn default() -> Self {
Self(build_pool()) Self(build_pool())
} }
} }
@ -37,7 +37,7 @@ pub fn build_pool() -> DbPool {
let manager = ConnectionManager::<PgConnection>::new(database_url.clone()); let manager = ConnectionManager::<PgConnection>::new(database_url.clone());
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let manager: ConnectionManager<VerboseConnection> = let manager: ConnectionManager<VerboseConnection> =
ConnectionManager::<dev::VerboseConnection>::new(database_url.clone()); ConnectionManager::<dev::VerboseConnection>::new(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))

View File

@ -2,6 +2,8 @@ use actix::{Handler, Message};
use diesel::prelude::*; use diesel::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use jirs_data::ProjectCategory;
use crate::db::DbExecutor; use crate::db::DbExecutor;
use crate::errors::ServiceErrors; use crate::errors::ServiceErrors;
use crate::models::Project; use crate::models::Project;
@ -25,8 +27,14 @@ impl Handler<LoadCurrentProject> for DbExecutor {
.get() .get()
.map_err(|_| ServiceErrors::DatabaseConnectionLost)?; .map_err(|_| ServiceErrors::DatabaseConnectionLost)?;
projects let query = projects.filter(id.eq(msg.project_id));
.filter(id.eq(msg.project_id))
debug!(
"{}",
diesel::debug_query::<diesel::pg::Pg, _>(&query).to_string()
);
query
.first::<Project>(conn) .first::<Project>(conn)
.map_err(|_| ServiceErrors::RecordNotFound("Project".to_string())) .map_err(|_| ServiceErrors::RecordNotFound("Project".to_string()))
} }
@ -38,7 +46,7 @@ pub struct UpdateProject {
pub name: Option<String>, pub name: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub category: Option<String>, pub category: Option<ProjectCategory>,
} }
impl Message for UpdateProject { impl Message for UpdateProject {

View File

@ -32,7 +32,7 @@ impl Into<HttpResponse> for ServiceErrors {
} }
ServiceErrors::DatabaseQueryFailed(error) => { ServiceErrors::DatabaseQueryFailed(error) => {
HttpResponse::BadRequest().json(ErrorResponse { HttpResponse::BadRequest().json(ErrorResponse {
errors: vec![error.to_owned()], errors: vec![error],
}) })
} }
ServiceErrors::RecordNotFound(resource_name) => { ServiceErrors::RecordNotFound(resource_name) => {

View File

@ -25,7 +25,7 @@ async fn main() -> Result<(), String> {
let bind = std::env::var("JIRS_SERVER_BIND").unwrap_or_else(|_| "0.0.0.0".to_string()); let bind = std::env::var("JIRS_SERVER_BIND").unwrap_or_else(|_| "0.0.0.0".to_string());
let addr = format!("{}:{}", bind, port); let addr = format!("{}:{}", bind, port);
let db_addr = actix::SyncArbiter::start(4, || crate::db::DbExecutor::new()); let db_addr = actix::SyncArbiter::start(4, crate::db::DbExecutor::default);
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()

View File

@ -1,10 +1,12 @@
use crate::db::SyncQuery; use std::task::{Context, Poll};
use actix_service::{Service, Transform}; use actix_service::{Service, Transform};
use actix_web::http::header::{self}; use actix_web::http::header::{self};
use actix_web::http::HeaderMap; use actix_web::http::HeaderMap;
use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error}; use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error};
use futures::future::{ok, FutureExt, LocalBoxFuture, Ready}; use futures::future::{ok, FutureExt, LocalBoxFuture, Ready};
use std::task::{Context, Poll};
use crate::db::SyncQuery;
type Db = actix_web::web::Data<crate::db::DbPool>; type Db = actix_web::web::Data<crate::db::DbPool>;
@ -82,7 +84,7 @@ pub fn token_from_headers(
headers: &HeaderMap, headers: &HeaderMap,
) -> std::result::Result<uuid::Uuid, crate::errors::ServiceErrors> { ) -> std::result::Result<uuid::Uuid, crate::errors::ServiceErrors> {
headers headers
.get(&header::AUTHORIZATION) .get(header::AUTHORIZATION)
.ok_or_else(|| crate::errors::ServiceErrors::Unauthorized) .ok_or_else(|| crate::errors::ServiceErrors::Unauthorized)
.map(|h| h.to_str().unwrap_or_default()) .map(|h| h.to_str().unwrap_or_default())
.and_then(|s| parse_bearer(s)) .and_then(|s| parse_bearer(s))

View File

@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use jirs_data::{IssuePriority, IssueStatus, IssueType}; use jirs_data::{IssuePriority, IssueStatus, IssueType, ProjectCategory};
use crate::schema::*; use crate::schema::*;
@ -127,7 +127,7 @@ pub struct Project {
pub name: String, pub name: String,
pub url: String, pub url: String,
pub description: String, pub description: String,
pub category: String, pub category: ProjectCategory,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@ -147,17 +147,15 @@ impl Into<jirs_data::Project> for Project {
} }
#[derive(Debug, Serialize, Deserialize, Insertable)] #[derive(Debug, Serialize, Deserialize, Insertable)]
#[serde(rename_all = "camelCase")]
#[table_name = "projects"] #[table_name = "projects"]
pub struct UpdateProjectForm { pub struct UpdateProjectForm {
pub name: Option<String>, pub name: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub category: Option<String>, pub category: Option<ProjectCategory>,
} }
#[derive(Debug, Serialize, Deserialize, Queryable)] #[derive(Debug, Serialize, Deserialize, Queryable)]
#[serde(rename_all = "camelCase")]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@ -190,8 +188,8 @@ impl Into<jirs_data::User> for &User {
email: self.email.clone(), email: self.email.clone(),
avatar_url: self.avatar_url.clone(), avatar_url: self.avatar_url.clone(),
project_id: self.project_id, project_id: self.project_id,
created_at: self.created_at.clone(), created_at: self.created_at,
updated_at: self.updated_at.clone(), updated_at: self.updated_at,
} }
} }
} }

View File

@ -223,10 +223,10 @@ table! {
description -> Text, description -> Text,
/// The `category` column of the `projects` table. /// The `category` column of the `projects` table.
/// ///
/// Its SQL type is `Text`. /// Its SQL type is `ProjectCategoryType`.
/// ///
/// (Automatically generated by Diesel.) /// (Automatically generated by Diesel.)
category -> Text, category -> ProjectCategoryType,
/// The `created_at` column of the `projects` table. /// The `created_at` column of the `projects` table.
/// ///
/// Its SQL type is `Timestamp`. /// Its SQL type is `Timestamp`.

View File

@ -25,11 +25,11 @@ pub async fn update_issue(
status: Some(payload.status), status: Some(payload.status),
priority: Some(payload.priority), priority: Some(payload.priority),
list_position: Some(payload.list_position), list_position: Some(payload.list_position),
description: Some(payload.description), description: payload.description,
description_text: Some(payload.description_text), description_text: payload.description_text,
estimate: Some(payload.estimate), estimate: payload.estimate,
time_spent: Some(payload.time_spent), time_spent: payload.time_spent,
time_remaining: Some(payload.time_remaining), time_remaining: payload.time_remaining,
project_id: Some(payload.project_id), project_id: Some(payload.project_id),
user_ids: Some(payload.user_ids), user_ids: Some(payload.user_ids),
reporter_id: Some(payload.reporter_id), reporter_id: Some(payload.reporter_id),
@ -40,12 +40,7 @@ pub async fn update_issue(
_ => return Ok(None), _ => return Ok(None),
}; };
let assignees = match db let assignees = match db.send(LoadAssignees { issue_id: issue.id }).await {
.send(LoadAssignees {
issue_id: issue.id.clone(),
})
.await
{
Ok(Ok(v)) => v, Ok(Ok(v)) => v,
_ => vec![], _ => vec![],
}; };
@ -53,7 +48,7 @@ pub async fn update_issue(
issue.user_ids.push(assignee.user_id); issue.user_ids.push(assignee.user_id);
} }
Ok(Some(WsMsg::IssueUpdated(issue.into()))) Ok(Some(WsMsg::IssueUpdated(issue)))
} }
pub async fn add_issue( pub async fn add_issue(
@ -109,22 +104,17 @@ pub async fn load_issues(db: &Data<Addr<DbExecutor>>, user: &Option<jirs_data::U
let mut issue_map = HashMap::new(); let mut issue_map = HashMap::new();
let mut queue = vec![]; let mut queue = vec![];
for issue in issues.into_iter() { for issue in issues.into_iter() {
let f = db.send(LoadAssignees { let f = db.send(LoadAssignees { issue_id: issue.id });
issue_id: issue.id.clone(),
});
queue.push(f); queue.push(f);
issue_map.insert(issue.id.clone(), issue); issue_map.insert(issue.id, issue);
} }
for f in queue { for f in queue {
match f.await { if let Ok(Ok(assignees)) = f.await {
Ok(Ok(assignees)) => {
for assignee in assignees { for assignee in assignees {
if let Some(issue) = issue_map.get_mut(&assignee.issue_id) { if let Some(issue) = issue_map.get_mut(&assignee.issue_id) {
issue.user_ids.push(assignee.user_id); issue.user_ids.push(assignee.user_id);
} }
} }
}
_ => {}
}; };
} }
let mut issues = vec![]; let mut issues = vec![];

View File

@ -75,6 +75,12 @@ impl WebSocketActor {
block_on(projects::current_project(&self.db, &self.current_user))? block_on(projects::current_project(&self.db, &self.current_user))?
} }
WsMsg::ProjectUpdateRequest(payload) => block_on(projects::update_project(
&self.db,
&self.current_user,
payload,
))?,
// auth // auth
WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?, WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?,
@ -114,6 +120,9 @@ impl WebSocketActor {
None None
} }
}; };
if msg.is_some() && msg != Some(WsMsg::Pong) {
info!("sending message {:?}", msg);
}
Ok(msg) Ok(msg)
} }

View File

@ -1,9 +1,9 @@
use actix::Addr; use actix::Addr;
use actix_web::web::Data; use actix_web::web::Data;
use jirs_data::WsMsg; use jirs_data::{UpdateProjectPayload, WsMsg};
use crate::db::users::LoadProjectUsers; use crate::db::projects::LoadCurrentProject;
use crate::db::DbExecutor; use crate::db::DbExecutor;
use crate::ws::{current_user, WsResult}; use crate::ws::{current_user, WsResult};
@ -13,11 +13,38 @@ pub async fn current_project(
) -> WsResult { ) -> WsResult {
let project_id = current_user(user).map(|u| u.project_id)?; let project_id = current_user(user).map(|u| u.project_id)?;
let m = match db.send(LoadProjectUsers { project_id }).await { let m = match db.send(LoadCurrentProject { project_id }).await {
Ok(Ok(v)) => Some(WsMsg::ProjectUsersLoaded( Ok(Ok(project)) => Some(WsMsg::ProjectLoaded(project.into())),
v.into_iter().map(|i| i.into()).collect(), Ok(Err(e)) => {
)), error!("{:?}", e);
_ => None, None
}
Err(e) => {
error!("{:?}", e);
None
}
}; };
Ok(m) Ok(m)
} }
pub async fn update_project(
db: &Data<Addr<DbExecutor>>,
user: &Option<jirs_data::User>,
payload: UpdateProjectPayload,
) -> WsResult {
let project_id = current_user(user).map(|u| u.project_id)?;
let project = match db
.send(crate::db::projects::UpdateProject {
project_id,
name: payload.name,
url: payload.url,
description: payload.description,
category: payload.category,
})
.await
{
Ok(Ok(project)) => project,
_ => return Ok(None),
};
Ok(Some(WsMsg::ProjectLoaded(project.into())))
}

View File

@ -1,6 +1,6 @@
import styled from 'styled-components'; import styled from 'styled-components';
import { sizes } from 'shared/utils/styles'; import { sizes } from '../shared/utils/styles';
const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40; const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40;

View File

@ -23,8 +23,8 @@ const Project = () => {
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project'); const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
if (!data) return <PageLoader />; if (!data) return <PageLoader/>;
if (error) return <PageError />; if (error) return <PageError/>;
const { project } = data; const { project } = data;
@ -38,22 +38,22 @@ const Project = () => {
}; };
return ( return (
<ProjectPage id={ 'ProjectPage' }> <ProjectPage id={'ProjectPage'}>
<NavbarLeft <NavbarLeft
issueSearchModalOpen={ issueSearchModalHelpers.open } issueSearchModalOpen={issueSearchModalHelpers.open}
issueCreateModalOpen={ issueCreateModalHelpers.open } issueCreateModalOpen={issueCreateModalHelpers.open}
/> />
<Sidebar project={ project }/> <Sidebar project={project}/>
{ issueSearchModalHelpers.isOpen() && ( {issueSearchModalHelpers.isOpen() && (
<Modal <Modal
isOpen isOpen
testid="modal:issue-search" testid="modal:issue-search"
variant="aside" variant="aside"
width={600} width={600}
onClose={issueSearchModalHelpers.close} onClose={issueSearchModalHelpers.close}
renderContent={() => <IssueSearch project={project} />} renderContent={() => <IssueSearch project={project}/>}
/> />
)} )}
@ -88,10 +88,10 @@ const Project = () => {
<Route <Route
path={`${match.path}/settings`} path={`${match.path}/settings`}
render={() => <ProjectSettings project={project} fetchProject={fetchProject} />} render={() => <ProjectSettings project={project} fetchProject={fetchProject}/>}
/> />
{match.isExact && <Redirect to={`${match.url}/board`} />} {match.isExact && <Redirect to={`${match.url}/board`}/>}
</ProjectPage> </ProjectPage>
); );
}; };