From db1ebc266fa528417bb408c0e0b349ecaee79dc9 Mon Sep 17 00:00:00 2001 From: Adrian Wozniak Date: Sat, 25 Apr 2020 22:20:16 +0200 Subject: [PATCH] Handle hourly time tracking. Hide time tracking if is set to untracked --- jirs-client/src/lib.rs | 12 +- jirs-client/src/modal/add_issue.rs | 19 ++-- jirs-client/src/modal/issue_details.rs | 83 ++++++-------- jirs-client/src/modal/mod.rs | 14 ++- jirs-client/src/modal/time_tracking.rs | 105 +++++++++++------- jirs-client/src/model.rs | 30 ++++- jirs-client/src/project_settings.rs | 40 +++++-- jirs-client/src/shared/styled_input.rs | 43 +++++++ jirs-client/src/shared/styled_select_child.rs | 54 ++++++--- jirs-client/src/shared/tracking_widget.rs | 58 +++++++--- jirs-client/src/users.rs | 7 +- jirs-data/src/lib.rs | 22 ++-- jirs-data/src/sql.rs | 4 +- .../2020-04-24-163323_add_settings/down.sql | 4 - .../2020-04-24-163323_add_settings/up.sql | 4 - jirs-server/src/db/issues.rs | 12 +- jirs-server/src/models.rs | 12 +- jirs-server/src/schema.rs | 12 +- jirs-server/src/ws/issues.rs | 6 +- jirs-server/src/ws/projects.rs | 11 +- 20 files changed, 348 insertions(+), 204 deletions(-) diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index cecce0d0..d4751b78 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -75,7 +75,7 @@ impl std::fmt::Display for FieldId { EditIssueModalSection::Issue(IssueFieldId::Estimate) => { f.write_str("estimateIssueEditModal") } - EditIssueModalSection::Issue(IssueFieldId::TimeSpend) => { + EditIssueModalSection::Issue(IssueFieldId::TimeSpent) => { f.write_str("timeSpendIssueEditModal") } EditIssueModalSection::Issue(IssueFieldId::TimeRemaining) => { @@ -97,7 +97,7 @@ impl std::fmt::Display for FieldId { IssueFieldId::Priority => f.write_str("issuePriorityAddIssueModal"), IssueFieldId::Status => f.write_str("addIssueModal-status"), IssueFieldId::Estimate => f.write_str("addIssueModal-estimate"), - IssueFieldId::TimeSpend => f.write_str("addIssueModal-timeSpend"), + IssueFieldId::TimeSpent => f.write_str("addIssueModal-timeSpend"), IssueFieldId::TimeRemaining => f.write_str("addIssueModal-timeRemaining"), IssueFieldId::ListPosition => f.write_str("addIssueModal-listPosition"), }, @@ -144,9 +144,16 @@ pub enum UsersPageChange { ResetForm, } +#[derive(Clone, Debug, PartialEq)] +pub enum ProjectPageChange { + ResetForm, + SubmitForm, +} + #[derive(Clone, Debug, PartialEq)] pub enum PageChanged { Users(UsersPageChange), + ProjectSettings(ProjectPageChange), } #[derive(Clone, Debug, PartialEq)] @@ -186,7 +193,6 @@ pub enum Msg { ProjectToggleOnlyMy, ProjectToggleRecentlyUpdated, ProjectClearFilters, - ProjectSaveChanges, // dragging IssueDragStarted(IssueId), diff --git a/jirs-client/src/modal/add_issue.rs b/jirs-client/src/modal/add_issue.rs index a7f75be0..00e6a82a 100644 --- a/jirs-client/src/modal/add_issue.rs +++ b/jirs-client/src/modal/add_issue.rs @@ -12,9 +12,8 @@ use crate::shared::styled_input::StyledInput; use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant}; use crate::shared::styled_select::StyledSelect; use crate::shared::styled_select::StyledSelectChange; -use crate::shared::styled_select_child::ToStyledSelectChild; use crate::shared::styled_textarea::StyledTextarea; -use crate::shared::ToNode; +use crate::shared::{ToChild, ToNode}; use crate::{FieldId, Msg}; pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { @@ -127,10 +126,10 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .options( IssueType::ordered() .iter() - .map(|t| t.to_select_child().name("type")) + .map(|t| t.to_child().name("type")) .collect(), ) - .selected(vec![modal.issue_type.to_select_child().name("type")]) + .selected(vec![modal.issue_type.to_child().name("type")]) .build() .into_node(); let issue_type_field = StyledField::build() @@ -175,7 +174,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { model .users .iter() - .map(|u| u.to_select_child().name("reporter")) + .map(|u| u.to_child().name("reporter")) .collect(), ) .selected( @@ -184,7 +183,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .iter() .filter_map(|user| { if user.id == reporter_id { - Some(user.to_select_child().name("reporter")) + Some(user.to_child().name("reporter")) } else { None } @@ -210,7 +209,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { model .users .iter() - .map(|u| u.to_select_child().name("assignees")) + .map(|u| u.to_child().name("assignees")) .collect(), ) .selected( @@ -219,7 +218,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .iter() .filter_map(|user| { if modal.user_ids.contains(&user.id) { - Some(user.to_select_child().name("assignees")) + Some(user.to_child().name("assignees")) } else { None } @@ -245,10 +244,10 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .options( IssuePriority::ordered() .iter() - .map(|p| p.to_select_child().name("priority")) + .map(|p| p.to_child().name("priority")) .collect(), ) - .selected(vec![modal.priority.to_select_child().name("priority")]) + .selected(vec![modal.priority.to_child().name("priority")]) .build() .into_node(); let issue_priority_field = StyledField::build() diff --git a/jirs-client/src/modal/issue_details.rs b/jirs-client/src/modal/issue_details.rs index 29821e34..e5e01ab1 100644 --- a/jirs-client/src/modal/issue_details.rs +++ b/jirs-client/src/modal/issue_details.rs @@ -11,10 +11,9 @@ use crate::shared::styled_field::StyledField; use crate::shared::styled_icon::Icon; 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::tracking_widget::tracking_link; -use crate::shared::ToNode; +use crate::shared::{ToChild, ToNode}; use crate::{EditIssueModalSection, FieldChange, FieldId, Msg}; pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { @@ -27,6 +26,9 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { modal.reporter_state.update(msg, orders); modal.assignees_state.update(msg, orders); modal.priority_state.update(msg, orders); + modal.estimate.update(msg); + modal.time_spent.update(msg); + modal.time_remaining.update(msg); match msg { Msg::WsMsg(WsMsg::IssueUpdated(issue)) => { @@ -136,25 +138,25 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { )); } Msg::StrInputChanged( - FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpend)), - value, + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)), + _value, ) => { - modal.payload.time_spent = value.parse::().ok(); + modal.payload.time_spent = modal.time_spent.represent_f64_as_i32(); send_ws_msg(WsMsg::IssueUpdateRequest( modal.id, - IssueFieldId::TimeSpend, - PayloadVariant::OptionF64(modal.payload.time_spent), + IssueFieldId::TimeSpent, + PayloadVariant::OptionI32(modal.payload.time_spent), )); } Msg::StrInputChanged( FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)), - value, + _value, ) => { - modal.payload.time_remaining = value.parse::().ok(); + modal.payload.time_remaining = modal.time_remaining.represent_f64_as_i32(); send_ws_msg(WsMsg::IssueUpdateRequest( modal.id, IssueFieldId::TimeRemaining, - PayloadVariant::OptionF64(modal.payload.time_remaining), + PayloadVariant::OptionI32(modal.payload.time_remaining), )); } Msg::ModalChanged(FieldChange::TabChanged( @@ -182,26 +184,15 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { } Msg::StrInputChanged( FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)), - value, - ) => match value.parse::() { - Ok(n) if !value.is_empty() => { - modal.payload.estimate = Some(n); - send_ws_msg(WsMsg::IssueUpdateRequest( - modal.id, - IssueFieldId::TimeRemaining, - PayloadVariant::OptionF64(modal.payload.estimate), - )); - } - _ if value.is_empty() => { - modal.payload.estimate = None; - send_ws_msg(WsMsg::IssueUpdateRequest( - modal.id, - IssueFieldId::TimeRemaining, - PayloadVariant::OptionF64(modal.payload.estimate), - )); - } - _ => {} - }, + _value, + ) => { + modal.payload.estimate = modal.estimate.represent_f64_as_i32(); + send_ws_msg(WsMsg::IssueUpdateRequest( + modal.id, + IssueFieldId::Estimate, + PayloadVariant::OptionI32(modal.payload.estimate), + )); + } Msg::SaveComment => { let msg = match modal.comment_form.id { Some(id) => WsMsg::UpdateComment(UpdateCommentPayload { @@ -338,14 +329,14 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node { .options( IssueType::ordered() .into_iter() - .map(|t| t.to_select_child().name("type")) + .map(|t| t.to_child().name("type")) .collect(), ) .selected(vec![{ let id = modal.id; let issue_type = &payload.issue_type; issue_type - .to_select_child() + .to_child() .name("type") .text(format!("{} - {}", issue_type, id)) }]) @@ -574,10 +565,10 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { .options( IssueStatus::ordered() .into_iter() - .map(|opt| opt.to_select_child().name("status")) + .map(|opt| opt.to_child().name("status")) .collect(), ) - .selected(vec![payload.status.to_select_child().name("status")]) + .selected(vec![payload.status.to_child().name("status")]) .valid(true) .build() .into_node(); @@ -599,7 +590,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { model .users .iter() - .map(|user| user.to_select_child().name("assignees")) + .map(|user| user.to_child().name("assignees")) .collect(), ) .selected( @@ -607,7 +598,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { .users .iter() .filter(|user| payload.user_ids.contains(&user.id)) - .map(|user| user.to_select_child().name("assignees")) + .map(|user| user.to_child().name("assignees")) .collect(), ) .build() @@ -629,7 +620,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { model .users .iter() - .map(|user| user.to_select_child().name("reporter")) + .map(|user| user.to_child().name("reporter")) .collect(), ) .selected( @@ -637,7 +628,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { .users .iter() .filter(|user| payload.reporter_id == user.id) - .map(|user| user.to_select_child().name("reporter")) + .map(|user| user.to_child().name("reporter")) .collect(), ) .build() @@ -658,10 +649,10 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { .options( IssuePriority::ordered() .into_iter() - .map(|p| p.to_select_child().name("priority")) + .map(|p| p.to_child().name("priority")) .collect(), ) - .selected(vec![payload.priority.to_select_child().name("priority")]) + .selected(vec![payload.priority.to_child().name("priority")]) .build() .into_node(); let priority_field = StyledField::build() @@ -681,17 +672,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { IssueFieldId::Estimate, ))) .valid(true) - .value( - payload - .estimate - .as_ref() - .map(|n| match time_tracking_type { - TimeTracking::Fibonacci => format!("{}", n), - TimeTracking::Hourly => format!("{:.1}", n), - _ => "".to_string(), - }) - .unwrap_or_default(), - ) + .value(modal.estimate.value.as_str()) .build() .into_node(); let estimate_field = StyledField::build() diff --git a/jirs-client/src/modal/mod.rs b/jirs-client/src/modal/mod.rs index 8399250a..21b9dbf7 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -1,6 +1,6 @@ use seed::{prelude::*, *}; -use jirs_data::WsMsg; +use jirs_data::{TimeTracking, WsMsg}; use crate::api::send_ws_msg; use crate::model::{AddIssueModal, EditIssueModal, ModalType, Model, Page}; @@ -12,7 +12,7 @@ use crate::{model, FieldChange, FieldId, Msg}; mod add_issue; mod confirm_delete_issue; mod issue_details; -mod time_tracking; +pub mod time_tracking; pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders) { match msg { @@ -96,12 +96,20 @@ pub fn view(model: &model::Model) -> Node { } fn push_edit_modal(issue_id: i32, model: &mut Model) { + let time_tracking_type = model + .project + .as_ref() + .map(|p| p.time_tracking) + .unwrap_or_else(|| TimeTracking::Untracked); let modal = { let issue = match find_issue(model, issue_id) { Some(issue) => issue, _ => return, }; - ModalType::EditIssue(issue_id, Box::new(EditIssueModal::new(issue))) + ModalType::EditIssue( + issue_id, + Box::new(EditIssueModal::new(issue, time_tracking_type)), + ) }; send_ws_msg(WsMsg::IssueCommentsRequest(issue_id)); model.modals.push(modal); diff --git a/jirs-client/src/modal/time_tracking.rs b/jirs-client/src/modal/time_tracking.rs index b11a2f94..563f3b77 100644 --- a/jirs-client/src/modal/time_tracking.rs +++ b/jirs-client/src/modal/time_tracking.rs @@ -1,16 +1,27 @@ use seed::{prelude::*, *}; -use jirs_data::{IssueFieldId, IssueId}; +use jirs_data::{IssueFieldId, IssueId, TimeTracking}; 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, StyledInputState}; use crate::shared::styled_modal::StyledModal; -use crate::shared::tracking_widget::tracking_widget; -use crate::shared::{find_issue, ToNode}; +use crate::shared::styled_select::StyledSelect; +use crate::shared::styled_select_child::*; +use crate::shared::tracking_widget::{fibonacci_values, tracking_widget}; +use crate::shared::{find_issue, ToChild, ToNode}; use crate::{EditIssueModalSection, FieldId, Msg}; +pub fn value_for_time_tracking(v: &Option, time_tracking_type: &TimeTracking) -> String { + match (time_tracking_type, v.as_ref()) { + (TimeTracking::Untracked, _) => "".to_string(), + (TimeTracking::Fibonacci, Some(n)) => n.to_string(), + (TimeTracking::Hourly, Some(n)) => format!("{:.1}", *n as f64 / 10.0f64), + _ => "".to_string(), + } +} + pub fn view(model: &Model, issue_id: IssueId) -> Node { let _issue = match find_issue(model, issue_id) { Some(issue) => issue, @@ -21,49 +32,28 @@ pub fn view(model: &Model, issue_id: IssueId) -> Node { Some(ModalType::EditIssue(_, modal)) => modal, _ => return empty![], }; + let time_tracking_type = model + .project + .as_ref() + .map(|p| p.time_tracking) + .unwrap_or_else(|| TimeTracking::Untracked); let modal_title = div![class!["modalTitle"], "Time tracking"]; let tracking = tracking_widget(model, edit_issue_modal); - let time_spent = StyledInput::build(FieldId::EditIssueModal(EditIssueModalSection::Issue( - IssueFieldId::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(EditIssueModalSection::Issue( - IssueFieldId::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 time_spent_field = time_tracking_field( + time_tracking_type, + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)), + "Time spent", + &edit_issue_modal.time_spent, + ); + let time_remaining_field = time_tracking_field( + time_tracking_type, + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)), + "Time remaining", + &edit_issue_modal.time_remaining, + ); let inputs = div![ class!["inputs"], @@ -89,3 +79,34 @@ pub fn view(model: &Model, issue_id: IssueId) -> Node { .build() .into_node() } + +fn time_tracking_field( + time_tracking_type: TimeTracking, + field_id: FieldId, + label: &str, + state: &StyledInputState, +) -> Node { + let input = match time_tracking_type { + TimeTracking::Untracked => empty![], + TimeTracking::Fibonacci => StyledSelect::build(field_id) + .selected(vec![(state.to_i32().unwrap_or_default() as u32).to_child()]) + .options( + fibonacci_values() + .into_iter() + .map(|v| v.to_child()) + .collect(), + ) + .build() + .into_node(), + TimeTracking::Hourly => StyledInput::build(field_id) + .state(state) + .valid(true) + .build() + .into_node(), + }; + StyledField::build() + .input(input) + .label(label) + .build() + .into_node() +} diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index d6668b58..80920e9c 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -5,8 +5,10 @@ use uuid::Uuid; use jirs_data::*; +use crate::modal::time_tracking::value_for_time_tracking; use crate::shared::styled_checkbox::StyledCheckboxState; use crate::shared::styled_editor::Mode; +use crate::shared::styled_input::StyledInputState; use crate::shared::styled_select::StyledSelectState; use crate::{EditIssueModalSection, FieldId, ProjectFieldId, HOST_URL}; @@ -37,6 +39,10 @@ pub struct EditIssueModal { pub assignees_state: StyledSelectState, pub priority_state: StyledSelectState, + pub estimate: StyledInputState, + pub time_spent: StyledInputState, + pub time_remaining: StyledInputState, + pub description_editor_mode: Mode, // comments @@ -44,7 +50,7 @@ pub struct EditIssueModal { } impl EditIssueModal { - pub fn new(issue: &Issue) -> Self { + pub fn new(issue: &Issue, time_tracking_type: TimeTracking) -> Self { Self { id: issue.id, link_copied: false, @@ -78,6 +84,18 @@ impl EditIssueModal { priority_state: StyledSelectState::new(FieldId::EditIssueModal( EditIssueModalSection::Issue(IssueFieldId::Priority), )), + estimate: StyledInputState::new( + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::Estimate)), + value_for_time_tracking(&issue.estimate, &time_tracking_type), + ), + time_spent: StyledInputState::new( + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeSpent)), + value_for_time_tracking(&issue.time_spent, &time_tracking_type), + ), + time_remaining: StyledInputState::new( + FieldId::EditIssueModal(EditIssueModalSection::Issue(IssueFieldId::TimeRemaining)), + value_for_time_tracking(&issue.time_remaining, &time_tracking_type), + ), description_editor_mode: Mode::View, comment_form: CommentForm { id: None, @@ -96,9 +114,9 @@ pub struct AddIssueModal { pub priority: IssuePriority, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: Option, pub user_ids: Vec, pub reporter_id: Option, @@ -212,6 +230,7 @@ impl ProjectSettingsPage { url, description, category, + time_tracking, .. } = project; Self { @@ -221,6 +240,7 @@ impl ProjectSettingsPage { url: Some(url.clone()), description: Some(description.clone()), category: Some(category.clone()), + time_tracking: Some(*time_tracking), }, description_mode: EditorMode::View, project_category_state: StyledSelectState::new(FieldId::ProjectSettings( @@ -228,7 +248,7 @@ impl ProjectSettingsPage { )), time_tracking: StyledCheckboxState::new( FieldId::ProjectSettings(ProjectFieldId::TimeTracking), - 0, + (*time_tracking).into(), ), } } diff --git a/jirs-client/src/project_settings.rs b/jirs-client/src/project_settings.rs index 613baf89..f06aafa9 100644 --- a/jirs-client/src/project_settings.rs +++ b/jirs-client/src/project_settings.rs @@ -1,6 +1,6 @@ use seed::{prelude::*, *}; -use jirs_data::{ProjectCategory, TimeTracking, ToVec, WsMsg}; +use jirs_data::{ProjectCategory, TimeTracking, ToVec, UpdateProjectPayload, WsMsg}; use crate::api::send_ws_msg; use crate::model::{Model, Page, PageContent, ProjectSettingsPage}; @@ -10,11 +10,13 @@ use crate::shared::styled_editor::StyledEditor; use crate::shared::styled_field::StyledField; use crate::shared::styled_form::StyledForm; 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, ToChild, ToNode}; use crate::FieldChange::TabChanged; -use crate::{model, FieldId, Msg, ProjectFieldId}; +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 they’re always being watched."; +static TIME_TRACKING_HOURLY: &'static str = "Employees may feel intimidated by demands to track their time. Or they could feel that they’re constantly being watched and evaluated. And for overly ambitious managers, employee time tracking may open the doors to excessive micromanaging."; pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { if model.user.is_none() { @@ -47,7 +49,6 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) page.time_tracking.update(&msg); match msg { - Msg::ProjectSaveChanges => send_ws_msg(WsMsg::ProjectUpdateRequest(page.payload.clone())), Msg::StrInputChanged(FieldId::ProjectSettings(ProjectFieldId::Name), text) => { page.payload.name = Some(text); } @@ -70,6 +71,16 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) )) => { page.description_mode = mode; } + Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::SubmitForm)) => { + 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()), + })); + } _ => (), } } @@ -142,7 +153,7 @@ pub fn view(model: &model::Model) -> Node { .options( ProjectCategory::ordered() .into_iter() - .map(|c| c.to_select_child()) + .map(|c| c.to_child()) .collect(), ) .selected(vec![page @@ -151,7 +162,7 @@ pub fn view(model: &model::Model) -> Node { .as_ref() .cloned() .unwrap_or_default() - .to_select_child()]) + .to_child()]) .build() .into_node(); let category_field = StyledField::build() @@ -174,23 +185,30 @@ pub fn view(model: &model::Model) -> Node { let time_tracking_type: TimeTracking = page.time_tracking.value.into(); let time_tracking_field = StyledField::build() .input(time_tracking) - .tip(if time_tracking_type == TimeTracking::Hourly { - "Employees may feel intimidated by demands to track their time. Or they could feel that they’re constantly being watched and evaluated. And for overly ambitious managers, employee time tracking may open the doors to excessive micromanaging." - } else { - "" + .tip(match time_tracking_type { + TimeTracking::Fibonacci => TIME_TRACKING_FIBONACCI, + TimeTracking::Hourly => TIME_TRACKING_HOURLY, + _ => "", }) .build() .into_node(); let save_button = StyledButton::build() .add_class("actionButton") - .on_click(mouse_ev(Ev::Click, |_| Msg::ProjectSaveChanges)) + .on_click(mouse_ev(Ev::Click, |ev| { + ev.prevent_default(); + Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::SubmitForm)) + })) .text("Save changes") .build() .into_node(); let form = StyledForm::build() .heading("Project Details") + .on_submit(ev(Ev::Submit, |ev| { + ev.prevent_default(); + Msg::PageChanged(PageChanged::ProjectSettings(ProjectPageChange::SubmitForm)) + })) .add_field(name_field) .add_field(url_field) .add_field(description_field) diff --git a/jirs-client/src/shared/styled_input.rs b/jirs-client/src/shared/styled_input.rs index 9fd2e22b..b1a6299c 100644 --- a/jirs-client/src/shared/styled_input.rs +++ b/jirs-client/src/shared/styled_input.rs @@ -4,6 +4,45 @@ use crate::shared::styled_icon::{Icon, StyledIcon}; use crate::shared::ToNode; use crate::{FieldId, Msg}; +#[derive(Clone, Debug, PartialOrd, PartialEq)] +pub struct StyledInputState { + id: FieldId, + pub value: String, +} + +impl StyledInputState { + pub fn new(id: FieldId, value: S) -> Self + where + S: Into, + { + Self { + id, + value: value.into(), + } + } + + pub fn to_i32(&self) -> Option { + self.value.parse::().ok() + } + + pub fn to_f64(&self) -> Option { + self.value.parse::().ok() + } + + pub fn represent_f64_as_i32(&self) -> Option { + self.to_f64().map(|f| (f * 10.0f64) as i32) + } + + pub fn update(&mut self, msg: &Msg) { + match msg { + Msg::StrInputChanged(field_id, s) if field_id == &self.id => { + self.value = s.clone(); + } + _ => (), + } + } +} + #[derive(Debug)] pub struct StyledInput { id: FieldId, @@ -59,6 +98,10 @@ impl StyledInputBuilder { self } + pub fn state(self, state: &StyledInputState) -> Self { + self.value(state.value.as_str()) + } + pub fn add_input_class(mut self, name: S) -> Self where S: Into, diff --git a/jirs-client/src/shared/styled_select_child.rs b/jirs-client/src/shared/styled_select_child.rs index c14fec19..41d8d225 100644 --- a/jirs-client/src/shared/styled_select_child.rs +++ b/jirs-client/src/shared/styled_select_child.rs @@ -1,13 +1,9 @@ use seed::{prelude::*, *}; use crate::shared::styled_select::Variant; -use crate::shared::ToNode; +use crate::shared::{ToChild, ToNode}; use crate::Msg; -pub trait ToStyledSelectChild { - fn to_select_child(&self) -> StyledSelectChildBuilder; -} - pub enum DisplayType { SelectOption, SelectValue, @@ -180,8 +176,10 @@ pub fn render(values: StyledSelectChild) -> Node { div![class![wrapper_class.as_str()], icon_node, label_node] } -impl ToStyledSelectChild for jirs_data::User { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::User { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> Self::Builder { let avatar = crate::shared::styled_avatar::StyledAvatar::build() .avatar_url(self.avatar_url.as_ref().cloned().unwrap_or_default()) .size(20) @@ -195,8 +193,9 @@ impl ToStyledSelectChild for jirs_data::User { } } -impl ToStyledSelectChild for jirs_data::IssuePriority { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::IssuePriority { + type Builder = StyledSelectChildBuilder; + fn to_child(&self) -> StyledSelectChildBuilder { let icon = crate::shared::styled_icon::StyledIcon::build(self.clone().into()) .add_class(self.to_string()) .build() @@ -211,8 +210,10 @@ impl ToStyledSelectChild for jirs_data::IssuePriority { } } -impl ToStyledSelectChild for jirs_data::IssueStatus { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::IssueStatus { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> StyledSelectChildBuilder { let text = self.to_label(); StyledSelectChild::build() @@ -222,8 +223,10 @@ impl ToStyledSelectChild for jirs_data::IssueStatus { } } -impl ToStyledSelectChild for jirs_data::IssueType { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::IssueType { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> StyledSelectChildBuilder { let name = self.to_label().to_owned(); let type_icon = crate::shared::styled_icon::StyledIcon::build(self.clone().into()) @@ -239,8 +242,10 @@ impl ToStyledSelectChild for jirs_data::IssueType { } } -impl ToStyledSelectChild for jirs_data::ProjectCategory { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::ProjectCategory { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> StyledSelectChildBuilder { let name = self.to_string(); StyledSelectChild::build() @@ -250,8 +255,10 @@ impl ToStyledSelectChild for jirs_data::ProjectCategory { } } -impl ToStyledSelectChild for jirs_data::UserRole { - fn to_select_child(&self) -> StyledSelectChildBuilder { +impl ToChild for jirs_data::UserRole { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> StyledSelectChildBuilder { let name = self.to_string(); StyledSelectChild::build() @@ -261,3 +268,16 @@ impl ToStyledSelectChild for jirs_data::UserRole { .value(self.clone().into()) } } + +impl ToChild for u32 { + type Builder = StyledSelectChildBuilder; + + fn to_child(&self) -> Self::Builder { + let name = self.to_string(); + + StyledSelectChild::build() + .add_class(name.as_str()) + .text(name) + .value(*self) + } +} diff --git a/jirs-client/src/shared/tracking_widget.rs b/jirs-client/src/shared/tracking_widget.rs index 46df575b..3e27ed29 100644 --- a/jirs-client/src/shared/tracking_widget.rs +++ b/jirs-client/src/shared/tracking_widget.rs @@ -2,11 +2,17 @@ use seed::{prelude::*, *}; use jirs_data::{TimeTracking, UpdateIssuePayload}; +use crate::modal::time_tracking::value_for_time_tracking; use crate::model::{EditIssueModal, ModalType, Model}; use crate::shared::styled_icon::{Icon, StyledIcon}; use crate::shared::ToNode; use crate::Msg; +#[inline] +pub fn fibonacci_values() -> Vec { + vec![0, 1, 2, 3, 5, 8, 13, 21, 34, 55] +} + pub fn tracking_link(model: &Model, modal: &EditIssueModal) -> Node { let EditIssueModal { id, .. } = modal; @@ -51,18 +57,18 @@ pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node { let bar_width = calc_bar_width(*estimate, *time_spent, *time_remaining); let spent_text = match (time_spent, time_tracking_type) { - (Some(time), TimeTracking::Hourly) => format!("{}h logged", time), - (Some(time), TimeTracking::Fibonacci) => format!("{} point logged", time), + (Some(time), TimeTracking::Hourly) => format!( + "{}h logged", + value_for_time_tracking(&Some(*time), &time_tracking_type) + ), + (Some(time), TimeTracking::Fibonacci) => format!( + "{} point logged", + value_for_time_tracking(&Some(*time), &time_tracking_type) + ), _ => "No time logged".to_string(), }; - let remaining_node: Node = match (time_remaining, estimate, time_tracking_type) { - (Some(n), _, TimeTracking::Hourly) => div![format!("{}h remaining", n)], - (_, Some(n), TimeTracking::Hourly) => div![format!("{}h estimated", n)], - (Some(n), _, TimeTracking::Fibonacci) => div![format!("{} remaining", n)], - (_, Some(n), TimeTracking::Fibonacci) => div![format!("{} estimated", n)], - _ => empty![], - }; + let remaining_node: Node = remaining_node(time_remaining, estimate, time_tracking_type); div![ class!["trackingWidget"], @@ -81,15 +87,39 @@ pub fn tracking_widget(model: &Model, modal: &EditIssueModal) -> Node { ] } +#[inline] +fn remaining_node( + time_remaining: &Option, + estimate: &Option, + time_tracking_type: TimeTracking, +) -> Node { + let text = match (time_remaining, estimate, time_tracking_type) { + (Some(n), _, TimeTracking::Hourly) => format!( + "{}h remaining", + value_for_time_tracking(&Some(*n), &time_tracking_type) + ), + (_, Some(n), TimeTracking::Hourly) => format!( + "{}h estimated", + value_for_time_tracking(&Some(*n), &time_tracking_type) + ), + (Some(n), _, TimeTracking::Fibonacci) => format!("{} remaining", n), + (_, Some(n), TimeTracking::Fibonacci) => format!("{} estimated", n), + _ => return empty![], + }; + div![text] +} + #[inline] fn calc_bar_width( - estimate: Option, - time_spent: Option, - time_remaining: Option, + estimate: Option, + time_spent: Option, + time_remaining: Option, ) -> f64 { match (estimate, time_spent, time_remaining) { - (_, Some(spent), Some(remaining)) => ((spent / (spent + remaining)) * 100f64).min(100f64), - (Some(estimate), Some(spent), _) => ((spent / estimate) * 100f64).min(100f64), + (_, Some(spent), Some(remaining)) => { + ((spent as f64 / (spent + remaining) as f64) * 100f64).min(100f64) + } + (Some(estimate), Some(spent), _) => ((spent / estimate) as f64 * 100f64).min(100f64), (None, None, _) => 100f64, (None, _, _) => 0f64, _ => 0f64, diff --git a/jirs-client/src/users.rs b/jirs-client/src/users.rs index 09eedf5b..298bf641 100644 --- a/jirs-client/src/users.rs +++ b/jirs-client/src/users.rs @@ -9,8 +9,7 @@ use crate::shared::styled_field::StyledField; use crate::shared::styled_form::StyledForm; use crate::shared::styled_input::StyledInput; use crate::shared::styled_select::*; -use crate::shared::styled_select_child::ToStyledSelectChild; -use crate::shared::{inner_layout, ToNode}; +use crate::shared::{inner_layout, ToChild, ToNode}; use crate::validations::is_email; use crate::{FieldId, Msg, PageChanged, UsersPageChange}; @@ -144,11 +143,11 @@ pub fn view(model: &Model) -> Node { .valid(true) .normal() .with_state(&page.user_role_state) - .selected(vec![page.user_role.to_select_child()]) + .selected(vec![page.user_role.to_child()]) .options( UserRole::ordered() .into_iter() - .map(|role| role.to_select_child()) + .map(|role| role.to_child()) .collect(), ) .build() diff --git a/jirs-data/src/lib.rs b/jirs-data/src/lib.rs index 1713d90e..dcb3c86f 100644 --- a/jirs-data/src/lib.rs +++ b/jirs-data/src/lib.rs @@ -487,9 +487,9 @@ pub struct Issue { pub list_position: i32, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub reporter_id: UserId, pub project_id: ProjectId, pub created_at: NaiveDateTime, @@ -557,9 +557,9 @@ pub struct UpdateIssuePayload { pub list_position: i32, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: ProjectId, pub reporter_id: UserId, pub user_ids: Vec, @@ -616,9 +616,9 @@ pub struct CreateIssuePayload { pub priority: IssuePriority, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: ProjectId, pub user_ids: Vec, pub reporter_id: UserId, @@ -631,12 +631,12 @@ pub struct UpdateProjectPayload { pub url: Option, pub description: Option, pub category: Option, + pub time_tracking: Option, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub enum PayloadVariant { OptionI32(Option), - OptionF64(Option), VecI32(Vec), I32(i32), String(String), @@ -696,7 +696,7 @@ pub enum IssueFieldId { Reporter, Priority, Estimate, - TimeSpend, + TimeSpent, TimeRemaining, } diff --git a/jirs-data/src/sql.rs b/jirs-data/src/sql.rs index d134e947..088f04f0 100644 --- a/jirs-data/src/sql.rs +++ b/jirs-data/src/sql.rs @@ -255,7 +255,7 @@ impl diesel::query_builder::QueryId for TimeTrackingType { fn time_tracking_from_sql(bytes: Option<&[u8]>) -> deserialize::Result { match not_none!(bytes) { b"untracked" => Ok(TimeTracking::Untracked), - b"fibnachi" => Ok(TimeTracking::Fibonacci), + b"fibonacci" => Ok(TimeTracking::Fibonacci), b"hourly" => Ok(TimeTracking::Hourly), _ => Ok(TimeTracking::Untracked), } @@ -277,7 +277,7 @@ impl ToSql for TimeTracking { fn to_sql(&self, out: &mut Output) -> serialize::Result { match *self { TimeTracking::Untracked => out.write_all(b"untracked")?, - TimeTracking::Fibonacci => out.write_all(b"fibnacci")?, + TimeTracking::Fibonacci => out.write_all(b"fibonacci")?, TimeTracking::Hourly => out.write_all(b"hourly")?, } Ok(IsNull::No) diff --git a/jirs-server/migrations/2020-04-24-163323_add_settings/down.sql b/jirs-server/migrations/2020-04-24-163323_add_settings/down.sql index d119c577..5a6b845e 100644 --- a/jirs-server/migrations/2020-04-24-163323_add_settings/down.sql +++ b/jirs-server/migrations/2020-04-24-163323_add_settings/down.sql @@ -1,7 +1,3 @@ -alter TABLE issues alter COLUMN estimate SET DATA TYPE integer; -alter TABLE issues alter COLUMN time_spent SET DATA TYPE integer; -alter TABLE issues alter COLUMN time_remaining SET DATA TYPE integer; - alter TABLE projects drop COLUMN time_tracking; drop TYPE IF EXISTS "TimeTrackingType"; diff --git a/jirs-server/migrations/2020-04-24-163323_add_settings/up.sql b/jirs-server/migrations/2020-04-24-163323_add_settings/up.sql index d2e4fb9a..3b753465 100644 --- a/jirs-server/migrations/2020-04-24-163323_add_settings/up.sql +++ b/jirs-server/migrations/2020-04-24-163323_add_settings/up.sql @@ -4,8 +4,4 @@ CREATE TYPE "TimeTrackingType" AS ENUM ( 'hourly' ); -ALTER TABLE issues ALTER COLUMN estimate SET DATA TYPE double precision; -ALTER TABLE issues ALTER COLUMN time_spent SET DATA TYPE double precision; -ALTER TABLE issues ALTER COLUMN time_remaining SET DATA TYPE double precision; - ALTER TABLE projects ADD COLUMN time_tracking "TimeTrackingType" NOT NULL DEFAULT 'untracked'; diff --git a/jirs-server/src/db/issues.rs b/jirs-server/src/db/issues.rs index 8a740f77..ea7a1fd7 100644 --- a/jirs-server/src/db/issues.rs +++ b/jirs-server/src/db/issues.rs @@ -79,9 +79,9 @@ pub struct UpdateIssue { pub list_position: Option, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: Option, pub user_ids: Option>, pub reporter_id: Option, @@ -209,9 +209,9 @@ pub struct CreateIssue { pub priority: IssuePriority, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: i32, pub reporter_id: i32, pub user_ids: Vec, diff --git a/jirs-server/src/models.rs b/jirs-server/src/models.rs index 57af1ed2..7ec986dc 100644 --- a/jirs-server/src/models.rs +++ b/jirs-server/src/models.rs @@ -26,9 +26,9 @@ pub struct Issue { pub list_position: i32, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub reporter_id: i32, pub project_id: i32, pub created_at: NaiveDateTime, @@ -69,9 +69,9 @@ pub struct CreateIssueForm { pub list_position: i32, pub description: Option, pub description_text: Option, - pub estimate: Option, - pub time_spent: Option, - pub time_remaining: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub reporter_id: i32, pub project_id: i32, } diff --git a/jirs-server/src/schema.rs b/jirs-server/src/schema.rs index 144ff6f5..46031d10 100644 --- a/jirs-server/src/schema.rs +++ b/jirs-server/src/schema.rs @@ -211,22 +211,22 @@ table! { description_text -> Nullable, /// The `estimate` column of the `issues` table. /// - /// Its SQL type is `Nullable`. + /// Its SQL type is `Nullable`. /// /// (Automatically generated by Diesel.) - estimate -> Nullable, + estimate -> Nullable, /// The `time_spent` column of the `issues` table. /// - /// Its SQL type is `Nullable`. + /// Its SQL type is `Nullable`. /// /// (Automatically generated by Diesel.) - time_spent -> Nullable, + time_spent -> Nullable, /// The `time_remaining` column of the `issues` table. /// - /// Its SQL type is `Nullable`. + /// Its SQL type is `Nullable`. /// /// (Automatically generated by Diesel.) - time_remaining -> Nullable, + time_remaining -> Nullable, /// The `reporter_id` column of the `issues` table. /// /// Its SQL type is `Int4`. diff --git a/jirs-server/src/ws/issues.rs b/jirs-server/src/ws/issues.rs index cfd6bda5..35baa527 100644 --- a/jirs-server/src/ws/issues.rs +++ b/jirs-server/src/ws/issues.rs @@ -51,13 +51,13 @@ impl WsHandler for WebSocketActor { (IssueFieldId::Priority, PayloadVariant::IssuePriority(p)) => { msg.priority = Some(p); } - (IssueFieldId::Estimate, PayloadVariant::OptionF64(o)) => { + (IssueFieldId::Estimate, PayloadVariant::OptionI32(o)) => { msg.estimate = o; } - (IssueFieldId::TimeSpend, PayloadVariant::OptionF64(o)) => { + (IssueFieldId::TimeSpent, PayloadVariant::OptionI32(o)) => { msg.time_spent = o; } - (IssueFieldId::TimeRemaining, PayloadVariant::OptionF64(o)) => { + (IssueFieldId::TimeRemaining, PayloadVariant::OptionI32(o)) => { msg.time_remaining = o; } _ => (), diff --git a/jirs-server/src/ws/projects.rs b/jirs-server/src/ws/projects.rs index 679fcee0..e128b109 100644 --- a/jirs-server/src/ws/projects.rs +++ b/jirs-server/src/ws/projects.rs @@ -35,10 +35,17 @@ impl WsHandler for WebSocketActor { url: msg.url, description: msg.description, category: msg.category, - time_tracking: None, + time_tracking: msg.time_tracking, })) { Ok(Ok(project)) => project, - _ => return Ok(None), + Ok(Err(e)) => { + error!("{:?}", e); + return Ok(None); + } + Err(e) => { + error!("{:?}", e); + return Ok(None); + } }; Ok(Some(WsMsg::ProjectLoaded(project))) }