diff --git a/jirs-client/Cargo.toml b/jirs-client/Cargo.toml index 680d90b1..9065c359 100644 --- a/jirs-client/Cargo.toml +++ b/jirs-client/Cargo.toml @@ -13,6 +13,10 @@ crate-type = ["cdylib", "rlib"] name = "jirs_client" path = "./src/lib.rs" +[profile.release] +lto = true +opt-level = 's' + [dependencies] jirs-data = { path = "../jirs-data" } seed = { version = "*" } diff --git a/jirs-client/js/css/issue.css b/jirs-client/js/css/issue.css index 5f933e57..38c22251 100644 --- a/jirs-client/js/css/issue.css +++ b/jirs-client/js/css/issue.css @@ -119,45 +119,6 @@ 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 */ /*===================================================*/ diff --git a/jirs-client/js/css/projectSettings.css b/jirs-client/js/css/projectSettings.css new file mode 100644 index 00000000..dc151b7e --- /dev/null +++ b/jirs-client/js/css/projectSettings.css @@ -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; +} diff --git a/jirs-client/js/css/timeTracking.css b/jirs-client/js/css/timeTracking.css index 06e830e9..8416587c 100644 --- a/jirs-client/js/css/timeTracking.css +++ b/jirs-client/js/css/timeTracking.css @@ -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 { padding: 20px 25px 25px; } @@ -6,7 +53,11 @@ padding-bottom: 14px; font-family: var(--font-medium); font-weight: normal; - font-size: 20px + font-size: 20px; +} + +.timeTrackingModal > .trackingWidget { + width: 100%; } .timeTrackingModal > .inputs { @@ -18,3 +69,8 @@ margin: 0 5px; width: 50%; } + +.timeTrackingModal > .actions { + display: flex; + justify-content: flex-end; +} diff --git a/jirs-client/js/index.js b/jirs-client/js/index.js index d783fa7f..e019a24e 100644 --- a/jirs-client/js/index.js +++ b/jirs-client/js/index.js @@ -30,6 +30,7 @@ import("../pkg/index.js").then(module => { buildWebSocket(); window.send_bin_code = code => queue.push(code); + window.inspectQueue = () => queue; let wsCheckDelay = 100; const flush = () => { diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index dcf6dfd3..1e441490 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -21,4 +21,5 @@ @import "./css/app.css"; @import "./css/issue.css"; @import "./css/project.css"; +@import "./css/projectSettings.css"; @import "./css/timeTracking.css"; diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 5c310e8f..501f23f3 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -138,6 +138,7 @@ pub enum Msg { ProjectToggleOnlyMy, ProjectToggleRecentlyUpdated, ProjectClearFilters, + ProjectSaveChanges, // dragging IssueDragStarted(IssueId), @@ -159,7 +160,7 @@ pub enum Msg { DeleteComment(CommentId), // modals - ModalOpened(ModalType), + ModalOpened(Box), ModalDropped, ModalChanged(FieldChange), @@ -175,7 +176,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { } match &msg { Msg::ChangePage(page) => { - model.page = page.clone(); + model.page = *page; } Msg::ToggleAboutTooltip => { model.about_tooltip_visible = !model.about_tooltip_visible; @@ -185,8 +186,7 @@ fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { crate::ws::update(&msg, model, orders); crate::modal::update(&msg, model, orders); match model.page { - Page::Project | Page::AddIssue => project::update(msg, model, orders), - Page::EditIssue(_id) => project::update(msg, model, orders), + Page::Project | Page::AddIssue | Page::EditIssue(..) => project::update(msg, model, orders), Page::ProjectSettings => project_settings::update(msg, model, orders), Page::Login => login::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() { v.push(a.get_index(idx)); } - match bincode::deserialize(v.as_slice()) { - Ok(msg) => { - ws::handle(msg); - } - _ => (), + if let Ok(msg) = bincode::deserialize(v.as_slice()) { + ws::handle(msg); }; } @@ -299,9 +296,8 @@ pub fn render() { .routes(routes) .build_and_start(); - match crate::shared::read_auth_token() { - Ok(uuid) => send_ws_msg(WsMsg::AuthorizeRequest(uuid)), - _ => (), + if let Ok(uuid) = crate::shared::read_auth_token() { + send_ws_msg(WsMsg::AuthorizeRequest(uuid)); }; let cell_app = std::sync::RwLock::new(app); diff --git a/jirs-client/src/modal/add_issue.rs b/jirs-client/src/modal/add_issue.rs index 71e86193..2e620f80 100644 --- a/jirs-client/src/modal/add_issue.rs +++ b/jirs-client/src/modal/add_issue.rs @@ -43,9 +43,9 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde priority: modal.priority.clone(), description: modal.description.clone(), description_text: modal.description_text.clone(), - estimate: modal.estimate.clone(), - time_spent: modal.time_spent.clone(), - time_remaining: modal.time_remaining.clone(), + estimate: modal.estimate, + time_spent: modal.time_spent, + time_remaining: modal.time_remaining, project_id: modal.project_id.unwrap_or(project_id), user_ids: modal.user_ids.clone(), reporter_id: modal.reporter_id.unwrap_or_else(|| user_id), diff --git a/jirs-client/src/modal/issue_details.rs b/jirs-client/src/modal/issue_details.rs index 77c4cfa9..3765ba87 100644 --- a/jirs-client/src/modal/issue_details.rs +++ b/jirs-client/src/modal/issue_details.rs @@ -8,11 +8,12 @@ use crate::shared::styled_avatar::StyledAvatar; use crate::shared::styled_button::StyledButton; use crate::shared::styled_editor::StyledEditor; 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_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::{EditIssueModalFieldId, FieldChange, FieldId, Msg}; @@ -89,6 +90,14 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { modal.payload.description_text = Some(value.clone()); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } + Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeSpend), value) => { + modal.payload.time_spent = value.parse::().ok(); + send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); + } + Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::TimeRemaining), value) => { + modal.payload.time_remaining = value.parse::().ok(); + send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); + } Msg::ModalChanged(FieldChange::TabChanged( FieldId::EditIssueModal(EditIssueModalFieldId::Description), mode, @@ -197,7 +206,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node { .. } = modal; - let issue_id = id.clone(); + let issue_id = *id; let click_handler = mouse_ev(Ev::Click, move |_| { use wasm_bindgen::JsCast; @@ -220,7 +229,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node { }); let close_handler = mouse_ev(Ev::Click, |_| Msg::ModalDropped); 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() @@ -261,7 +270,7 @@ fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node { .collect(), ) .selected(vec![{ - let id = modal.id.clone(); + let id = modal.id; let issue_type = &payload.issue_type; issue_type .to_select_child() @@ -424,7 +433,7 @@ fn comment(model: &Model, modal: &EditIssueModal, comment: &Comment) -> Option Node { .estimate .as_ref() .map(|n| n.to_string()) - .clone() .unwrap_or_default(), ) .build() @@ -593,7 +601,7 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - let tracking = tracking_widget(model, modal); + let tracking = tracking_link(model, modal); let tracking_field = StyledField::build() .label("TIME TRACKING") .tip("") @@ -611,68 +619,3 @@ fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { tracking_field, ] } - -fn tracking_widget(_model: &Model, modal: &EditIssueModal) -> Node { - 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, - time_spent: &Option, - time_remaining: &Option, -) -> 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, - } -} diff --git a/jirs-client/src/modal/mod.rs b/jirs-client/src/modal/mod.rs index 7ef378bb..b8b1129d 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -33,18 +33,18 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders } 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() => { - push_edit_modal(&issue_id, model) + push_edit_modal(issue_id, model) } _ => (), }, Msg::ChangePage(Page::EditIssue(issue_id)) => { - push_edit_modal(issue_id, model); + push_edit_modal(*issue_id, model); } Msg::ChangePage(Page::AddIssue) => { @@ -95,14 +95,14 @@ pub fn view(model: &model::Model) -> Node { 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 issue = match find_issue(model, *issue_id) { + let issue = match find_issue(model, issue_id) { Some(issue) => issue, _ => 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); } diff --git a/jirs-client/src/modal/time_tracking.rs b/jirs-client/src/modal/time_tracking.rs index 127e1eb8..e193b429 100644 --- a/jirs-client/src/modal/time_tracking.rs +++ b/jirs-client/src/modal/time_tracking.rs @@ -2,27 +2,88 @@ use seed::{prelude::*, *}; 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_modal::StyledModal; +use crate::shared::tracking_widget::tracking_widget; use crate::shared::{find_issue, ToNode}; -use crate::Msg; +use crate::{EditIssueModalFieldId, FieldId, Msg, ProjectSettingsFieldId}; pub fn view(model: &Model, issue_id: IssueId) -> Node { - let issue = match find_issue(model, issue_id) { + let _issue = match find_issue(model, issue_id) { Some(issue) => issue, _ => 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 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() .add_class("timeTrackingModal") - .children(vec![modal_title, inputs]) + .children(vec![ + modal_title, + tracking, + inputs, + div![class!["actions"], close], + ]) + .width(400) .build() .into_node() } diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index 5464af9e..6def4075 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -7,7 +7,9 @@ use jirs_data::*; use crate::shared::styled_editor::Mode; 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)] pub enum ModalType { @@ -52,14 +54,14 @@ impl EditIssueModal { issue_type: issue.issue_type.clone(), status: issue.status.clone(), priority: issue.priority.clone(), - list_position: issue.list_position.clone(), + list_position: issue.list_position, description: issue.description.clone(), description_text: issue.description_text.clone(), - estimate: issue.estimate.clone(), - time_spent: issue.time_spent.clone(), - time_remaining: issue.time_remaining.clone(), - project_id: issue.project_id.clone(), - reporter_id: issue.reporter_id.clone(), + estimate: issue.estimate, + time_spent: issue.time_spent, + time_remaining: issue.time_remaining, + project_id: issue.project_id, + reporter_id: issue.reporter_id, user_ids: issue.user_ids.clone(), }, top_type_state: StyledSelectState::new(FieldId::EditIssueModal( @@ -151,11 +153,11 @@ pub enum Page { } impl Page { - pub fn to_path(&self) -> String { + pub fn to_path(self) -> String { match self { Page::Project => "/board".to_string(), 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::Login => "/login".to_string(), Page::Register => "/register".to_string(), @@ -193,7 +195,35 @@ pub struct ProjectPage { #[derive(Debug)] pub struct ProjectSettingsPage { 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)] diff --git a/jirs-client/src/project.rs b/jirs-client/src/project.rs index 1dbe4ce8..ed1f55cf 100644 --- a/jirs-client/src/project.rs +++ b/jirs-client/src/project.rs @@ -4,7 +4,7 @@ use seed::{prelude::*, *}; use jirs_data::*; 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_button::StyledButton; use crate::shared::styled_icon::{Icon, StyledIcon}; @@ -14,16 +14,23 @@ use crate::shared::{drag_ev, inner_layout, ToNode}; use crate::{EditIssueModalFieldId, FieldId, Msg}; pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { + 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 { PageContent::Project(project_page) => project_page, _ => return, }; match msg { - Msg::ChangePage(Page::Project) + Msg::WsMsg(WsMsg::AuthorizeLoaded(..)) + | Msg::ChangePage(Page::Project) | Msg::ChangePage(Page::AddIssue) - | Msg::ChangePage(Page::EditIssue(..)) - | Msg::WsMsg(WsMsg::AuthorizeLoaded(Ok(_))) => { + | Msg::ChangePage(Page::EditIssue(..)) => { send_ws_msg(jirs_data::WsMsg::ProjectRequest); send_ws_msg(jirs_data::WsMsg::ProjectIssuesRequest); 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) => { project_page.text_filter = text; } - Msg::ProjectAvatarFilterChanged(user_id, active) => match active { - true => { + Msg::ProjectAvatarFilterChanged(user_id, active) => { + if active { project_page.active_avatar_filters = project_page .active_avatar_filters .iter() - .filter(|id| **id != user_id) - .map(|id| *id) + .filter_map(|id| if *id != user_id { Some(*id) } else { None }) .collect(); - } - false => { + } else { project_page.active_avatar_filters.push(user_id); } - }, + } Msg::ProjectToggleOnlyMy => { project_page.only_my_filter = !project_page.only_my_filter; } @@ -184,16 +189,17 @@ fn project_board_filters(model: &Model) -> Node { .build() .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.active_avatar_filters.is_empty() { - true => seed::button![ + seed::button![ id!["clearAllFilters"], "Clear all", mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters), - ], - false => empty![], + ] + } else { + empty![] }; div![ @@ -252,11 +258,11 @@ fn project_issue_list(model: &Model, status: jirs_data::IssueStatus) -> Node project_page, _ => return empty![], }; - let ids = if project_page.recently_updated_filter { + let ids: Vec = if project_page.recently_updated_filter { let mut v: Vec<(IssueId, NaiveDateTime)> = model .issues .iter() - .map(|issue| (issue.id, issue.updated_at.clone())) + .map(|issue| (issue.id, issue.updated_at)) .collect(); v.sort_by(|(_, a_time), (_, b_time)| a_time.cmp(b_time)); 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) } #[inline] -fn issue_filter_with_only_recent(issue: &Issue, ids: &Vec) -> bool { +fn issue_filter_with_only_recent(issue: &Issue, ids: &[IssueId]) -> bool { ids.is_empty() || ids.contains(&issue.id) } diff --git a/jirs-client/src/project_settings.rs b/jirs-client/src/project_settings.rs index f4d4cb7f..19303859 100644 --- a/jirs-client/src/project_settings.rs +++ b/jirs-client/src/project_settings.rs @@ -1,18 +1,92 @@ 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_field::StyledField; 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::FieldChange::TabChanged; use crate::{model, FieldId, Msg, ProjectSettingsFieldId}; -pub fn update(_msg: Msg, _model: &mut model::Model, _orders: &mut impl Orders) {} +pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { + 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 { - let name = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Name)) - .valid(true) - .build() + let page = match &model.page_content { + PageContent::ProjectSettings(page) => page, + _ => 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(); let name_field = StyledField::build() .label("Name") @@ -21,9 +95,12 @@ pub fn view(model: &model::Model) -> Node { .build() .into_node(); - let url = StyledInput::build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url)) - .valid(true) - .build() + let url = StyledTextarea::build() + .height(39) + .max_height(39) + .disable_auto_resize() + .value(page.payload.url.as_ref().cloned().unwrap_or_default()) + .build(FieldId::ProjectSettings(ProjectSettingsFieldId::Url)) .into_node(); let url_field = StyledField::build() .label("Url") @@ -35,8 +112,15 @@ pub fn view(model: &model::Model) -> Node { let description = StyledEditor::build(FieldId::ProjectSettings( ProjectSettingsFieldId::Description, )) - .text("") + .text( + page.payload + .description + .as_ref() + .cloned() + .unwrap_or_default(), + ) .update_on(Ev::Change) + .mode(page.description_mode.clone()) .build() .into_node(); let description_field = StyledField::build() @@ -46,15 +130,50 @@ pub fn view(model: &model::Model) -> Node { .build() .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() .heading("Project Details") .add_field(name_field) .add_field(url_field) .add_field(description_field) + .add_field(category_field) + .add_field(save_button) .build() .into_node(); - let project_section = vec![form]; + let project_section = vec![div![class!["formContainer"], form]]; inner_layout(model, "projectSettings", project_section, empty![]) } diff --git a/jirs-client/src/shared/aside.rs b/jirs-client/src/shared/aside.rs index d0483153..7f636d47 100644 --- a/jirs-client/src/shared/aside.rs +++ b/jirs-client/src/shared/aside.rs @@ -14,7 +14,10 @@ pub fn render(model: &Model) -> Node { div![ attrs![At::Class => "projectTexts";], div![attrs![At::Class => "projectName";], project.name], - div![attrs![At::Class => "projectCategory";], project.category] + div![ + attrs![At::Class => "projectCategory";], + project.category.to_string() + ] ], ], _ => li![ @@ -50,7 +53,7 @@ pub fn render(model: &Model) -> Node { fn sidebar_link_item(model: &Model, name: &str, icon: Icon, page: Option) -> Node { let path = page.map(|ref p| p.to_path()).unwrap_or_default(); 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()); }; if Some(model.page) == page { diff --git a/jirs-client/src/shared/mod.rs b/jirs-client/src/shared/mod.rs index 839c9ae2..657c5940 100644 --- a/jirs-client/src/shared/mod.rs +++ b/jirs-client/src/shared/mod.rs @@ -21,6 +21,7 @@ pub mod styled_select; pub mod styled_select_child; pub mod styled_textarea; pub mod styled_tooltip; +pub mod tracking_widget; pub fn find_issue(model: &Model, issue_id: IssueId) -> Option<&Issue> { model.issues.iter().find(|issue| issue.id == issue_id) diff --git a/jirs-client/src/shared/styled_button.rs b/jirs-client/src/shared/styled_button.rs index 9844cabc..a3a62dfa 100644 --- a/jirs-client/src/shared/styled_button.rs +++ b/jirs-client/src/shared/styled_button.rs @@ -30,9 +30,9 @@ pub struct StyledButtonBuilder { variant: Option, disabled: Option, active: Option, - text: Option>, - icon: Option>>, - on_click: Option>>, + text: Option, + icon: Option>, + on_click: Option>, children: Option>>, class_list: Vec, } @@ -77,7 +77,7 @@ impl StyledButtonBuilder { where S: Into, { - self.text = Some(Some(value.into())); + self.text = Some(value.into()); self } @@ -85,12 +85,12 @@ impl StyledButtonBuilder { where I: ToNode, { - self.icon = Some(Some(value.into_node())); + self.icon = Some(value.into_node()); self } pub fn on_click(mut self, value: EventHandler) -> Self { - self.on_click = Some(Some(value)); + self.on_click = Some(value); self } @@ -112,9 +112,9 @@ impl StyledButtonBuilder { variant: self.variant.unwrap_or_else(|| Variant::Primary), disabled: self.disabled.unwrap_or_else(|| false), active: self.active.unwrap_or_else(|| false), - text: self.text.unwrap_or_default(), - icon: self.icon.unwrap_or_else(|| None), - on_click: self.on_click.unwrap_or_else(|| None), + text: self.text, + icon: self.icon, + on_click: self.on_click, children: self.children.unwrap_or_default(), class_list: self.class_list, } @@ -187,9 +187,10 @@ pub fn render(values: StyledButton) -> Node { At::Class => class_list.join(" "), ], handler, - match disabled { - true => vec![attrs![At::Disabled => true]], - false => vec![], + if disabled { + vec![attrs![At::Disabled => true]] + } else { + vec![] }, icon_node, content, diff --git a/jirs-client/src/shared/styled_confirm_modal.rs b/jirs-client/src/shared/styled_confirm_modal.rs index d8bcfca8..4491a3fe 100644 --- a/jirs-client/src/shared/styled_confirm_modal.rs +++ b/jirs-client/src/shared/styled_confirm_modal.rs @@ -38,7 +38,7 @@ pub struct StyledConfirmModalBuilder { message: Option, confirm_text: Option, cancel_text: Option, - on_confirm: Option>>, + on_confirm: Option>, } impl StyledConfirmModalBuilder { @@ -75,7 +75,7 @@ impl StyledConfirmModalBuilder { } pub fn on_confirm(mut self, on_confirm: EventHandler) -> Self { - self.on_confirm = Some(Some(on_confirm)); + self.on_confirm = Some(on_confirm); self } @@ -87,7 +87,7 @@ impl StyledConfirmModalBuilder { .confirm_text .unwrap_or_else(|| CONFIRM_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, } } } diff --git a/jirs-client/src/shared/styled_editor.rs b/jirs-client/src/shared/styled_editor.rs index d6e02c22..5f2f8b40 100644 --- a/jirs-client/src/shared/styled_editor.rs +++ b/jirs-client/src/shared/styled_editor.rs @@ -88,24 +88,24 @@ pub fn render(values: StyledEditor) -> Node { let field_id = id.clone(); let on_editor_clicked = mouse_ev(Ev::Click, move |ev| { 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 on_view_clicked = mouse_ev(Ev::Click, move |ev| { 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 view_id = format!("view-{}", id.clone()); - let name = format!("styled-editor-{}", id.clone()); + let editor_id = format!("editor-{}", id); + let view_id = format!("view-{}", id); + let name = format!("styled-editor-{}", id); let text_area = StyledTextarea::build() .height(40) .update_on(update_event) .value(text.as_str()) - .build(id.clone()) + .build(id) .into_node(); let parsed = comrak::markdown_to_html( diff --git a/jirs-client/src/shared/styled_form.rs b/jirs-client/src/shared/styled_form.rs index 4cd32e30..99d55e98 100644 --- a/jirs-client/src/shared/styled_form.rs +++ b/jirs-client/src/shared/styled_form.rs @@ -54,8 +54,8 @@ pub fn render(values: StyledForm) -> Node { div![ attrs![At::Class => "styledForm"], div![ - attrs![At::Class => "formElement"], - div![attrs![At::Class => "formHeading"], heading], + class!["formElement"], + div![class!["formHeading"], heading], fields ], ] diff --git a/jirs-client/src/shared/styled_icon.rs b/jirs-client/src/shared/styled_icon.rs index f57d3796..ab5639de 100644 --- a/jirs-client/src/shared/styled_icon.rs +++ b/jirs-client/src/shared/styled_icon.rs @@ -132,14 +132,14 @@ impl ToNode for Icon { pub struct StyledIconBuilder { icon: Icon, - size: Option>, + size: Option, class_list: Vec, style_list: Vec, } impl StyledIconBuilder { pub fn size(mut self, size: i32) -> Self { - self.size = Some(Some(size)); + self.size = Some(size); self } @@ -162,7 +162,7 @@ impl StyledIconBuilder { pub fn build(self) -> StyledIcon { StyledIcon { icon: self.icon, - size: self.size.unwrap_or_default(), + size: self.size, class_list: self.class_list, style_list: self.style_list, } diff --git a/jirs-client/src/shared/styled_input.rs b/jirs-client/src/shared/styled_input.rs index 3217c4e9..ae05775f 100644 --- a/jirs-client/src/shared/styled_input.rs +++ b/jirs-client/src/shared/styled_input.rs @@ -88,26 +88,29 @@ pub fn render(values: StyledInput) -> Node { Some(icon) => StyledIcon::build(icon).build().into_node(), _ => empty![], }; - - let mut handlers = vec![]; - - handlers.push(input_ev(Ev::Change, move |value| { - Msg::InputChanged(id, value) - })); + let field_id = id.clone(); + let change_handler = keyboard_ev(Ev::KeyUp, move |event| { + use wasm_bindgen::JsCast; + event.stop_propagation(); + let value = event + .target() + .unwrap() + .dyn_ref::() + .unwrap() + .value(); + log!("asd"); + Msg::InputChanged(field_id, value) + }); div![ attrs!(At::Class => wrapper_class_list.join(" ")), icon, - keyboard_ev(Ev::KeyUp, |ev| { - ev.stop_propagation(); - Msg::NoOp - }), seed::input![ attrs![ At::Class => input_class_list.join(" "), At::Value => value.unwrap_or_default(), ], - handlers + change_handler, ], ] } diff --git a/jirs-client/src/shared/styled_select.rs b/jirs-client/src/shared/styled_select.rs index d7e5b418..979592a9 100644 --- a/jirs-client/src/shared/styled_select.rs +++ b/jirs-client/src/shared/styled_select.rs @@ -112,8 +112,8 @@ impl StyledSelect { pub struct StyledSelectBuilder { id: FieldId, variant: Option, - dropdown_width: Option>, - name: Option>, + dropdown_width: Option, + name: Option, valid: Option, is_multi: Option, allow_clear: Option, @@ -128,8 +128,8 @@ impl StyledSelectBuilder { StyledSelect { id: self.id, variant: self.variant.unwrap_or_default(), - dropdown_width: self.dropdown_width.unwrap_or_default(), - name: self.name.unwrap_or_default(), + dropdown_width: self.dropdown_width, + name: self.name, valid: self.valid.unwrap_or(true), is_multi: self.is_multi.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 { - self.dropdown_width = Some(Some(dropdown_width)); + self.dropdown_width = Some(dropdown_width); self } @@ -149,7 +149,7 @@ impl StyledSelectBuilder { where S: Into, { - self.name = Some(Some(name.into())); + self.name = Some(name.into()); self } @@ -224,19 +224,20 @@ pub fn render(values: StyledSelect) -> Node { let dropdown_style = dropdown_width .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)]; if !valid { select_class.push("invalid".to_string()); } - let chevron_down = match (selected.is_empty() || !is_multi) && variant != Variant::Empty { - true => StyledIcon::build(Icon::ChevronDown) + let chevron_down = if (selected.is_empty() || !is_multi) && variant != Variant::Empty { + StyledIcon::build(Icon::ChevronDown) .add_class("chevronIcon") .build() - .into_node(), - _ => empty![], + .into_node() + } else { + empty![] }; let children: Vec> = options @@ -259,8 +260,8 @@ pub fn render(values: StyledSelect) -> Node { }) .collect(); - let text_input = match opened { - true => seed::input![ + let text_input = if opened { + seed::input![ attrs![ At::Name => name.unwrap_or_default(), At::Class => "dropDownInput", @@ -268,9 +269,10 @@ pub fn render(values: StyledSelect) -> Node { At::Placeholder => "Search" At::AutoFocus => "true", ], - on_text.clone(), - ], - _ => empty![], + on_text, + ] + } else { + empty![] }; let clear_icon = match (opened, allow_clear) { diff --git a/jirs-client/src/shared/styled_select_child.rs b/jirs-client/src/shared/styled_select_child.rs index e8d603d7..247d036a 100644 --- a/jirs-client/src/shared/styled_select_child.rs +++ b/jirs-client/src/shared/styled_select_child.rs @@ -217,7 +217,7 @@ impl ToStyledSelectChild for jirs_data::IssueStatus { StyledSelectChild::build() .value(self.clone().into()) - .add_class(text.clone()) + .add_class(text) .text(text) } } @@ -238,3 +238,14 @@ impl ToStyledSelectChild for jirs_data::IssueType { .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()) + } +} diff --git a/jirs-client/src/shared/styled_textarea.rs b/jirs-client/src/shared/styled_textarea.rs index 26d28075..cca11c47 100644 --- a/jirs-client/src/shared/styled_textarea.rs +++ b/jirs-client/src/shared/styled_textarea.rs @@ -12,6 +12,7 @@ pub struct StyledTextarea { class_list: Vec, update_event: Ev, placeholder: Option, + disable_auto_resize: bool, } impl ToNode for StyledTextarea { @@ -35,6 +36,7 @@ pub struct StyledTextareaBuilder { class_list: Vec, update_event: Option, placeholder: Option, + disable_auto_resize: bool, } impl StyledTextareaBuilder { @@ -81,6 +83,11 @@ impl StyledTextareaBuilder { self } + pub fn disable_auto_resize(mut self) -> Self { + self.disable_auto_resize = true; + self + } + #[inline] pub fn build(self, id: FieldId) -> StyledTextarea { StyledTextarea { @@ -91,6 +98,7 @@ impl StyledTextareaBuilder { max_height: self.max_height.unwrap_or_default(), update_event: self.update_event.unwrap_or_else(|| Ev::KeyUp), placeholder: self.placeholder, + disable_auto_resize: self.disable_auto_resize, } } } @@ -117,10 +125,11 @@ pub fn render(values: StyledTextarea) -> Node { mut class_list, update_event, placeholder, + disable_auto_resize, } = values; 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 { style_list.push(format!("min-height: {}px", min_height)); } @@ -129,8 +138,13 @@ pub fn render(values: StyledTextarea) -> Node { 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 handler_disable_auto_resize = disable_auto_resize; let resize_handler = ev(Ev::KeyUp, move |event| { use wasm_bindgen::JsCast; @@ -140,7 +154,7 @@ pub fn render(values: StyledTextarea) -> Node { }; let text_area = target.dyn_ref::().unwrap(); 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 .style() @@ -148,11 +162,9 @@ pub fn render(values: StyledTextarea) -> Node { Msg::NoOp }); handlers.push(resize_handler); - let text_input_handler = input_ev(update_event.clone(), move |value| { - Msg::InputChanged(id, value) - }); + let text_input_handler = input_ev(update_event, move |value| Msg::InputChanged(id, value)); handlers.push(text_input_handler); - handlers.push(keyboard_ev(Ev::KeyUp, |ev| { + handlers.push(keyboard_ev(Ev::Input, |ev| { ev.stop_propagation(); Msg::NoOp })); @@ -175,9 +187,12 @@ pub fn render(values: StyledTextarea) -> Node { ] } -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; - if value.chars().last() == Some('\n') {} + // if value.chars().last() == Some('\n') {} let calc_height = (len * LETTER_HEIGHT) + ADDITIONAL_HEIGHT; if calc_height + ADDITIONAL_HEIGHT < min_height { diff --git a/jirs-client/src/shared/tracking_widget.rs b/jirs-client/src/shared/tracking_widget.rs new file mode 100644 index 00000000..3dbb5df6 --- /dev/null +++ b/jirs-client/src/shared/tracking_widget.rs @@ -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 { + 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 { + 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 = 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, + time_spent: Option, + time_remaining: Option, +) -> 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, + } +} diff --git a/jirs-client/src/ws/issue.rs b/jirs-client/src/ws/issue.rs index 5fbaa51e..9457d4db 100644 --- a/jirs-client/src/ws/issue.rs +++ b/jirs-client/src/ws/issue.rs @@ -150,7 +150,7 @@ pub fn change_status(status: IssueStatus, model: &mut Model) { model.issues.push(issue); return; } - issue.status = status.clone(); + issue.status = status; issue.list_position = pos + 1; model.issues.push(issue); diff --git a/jirs-data/src/lib.rs b/jirs-data/src/lib.rs index 31225674..ac75b33e 100644 --- a/jirs-data/src/lib.rs +++ b/jirs-data/src/lib.rs @@ -26,7 +26,6 @@ pub type TokenId = i32; #[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))] #[cfg_attr(feature = "backend", sql_type = "IssueTypeType")] #[derive(Clone, Deserialize, Serialize, Debug, PartialOrd, PartialEq, Hash)] -#[serde(rename_all = "lowercase")] pub enum IssueType { Task, Bug, @@ -248,6 +247,77 @@ impl Into 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 { + vec![ + ProjectCategory::Software, + ProjectCategory::Marketing, + ProjectCategory::Business, + ] + } +} + +impl FromStr for ProjectCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + 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 for ProjectCategory { + fn into(self) -> u32 { + match self { + ProjectCategory::Software => 0, + ProjectCategory::Marketing => 1, + ProjectCategory::Business => 2, + } + } +} + +impl Into for u32 { + fn into(self) -> ProjectCategory { + match self { + 0 => ProjectCategory::Software, + 1 => ProjectCategory::Marketing, + 2 => ProjectCategory::Business, + _ => ProjectCategory::Software, + } + } +} + #[derive(Clone, Serialize, Debug, PartialEq)] pub struct ErrorResponse { pub errors: Vec, @@ -259,7 +329,7 @@ pub struct Project { pub name: String, pub url: String, pub description: String, - pub category: String, + pub category: ProjectCategory, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } @@ -382,12 +452,13 @@ pub struct CreateIssuePayload { pub reporter_id: UserId, } -#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct UpdateProjectPayload { + pub id: ProjectId, pub name: Option, pub url: Option, pub description: Option, - pub category: Option, + pub category: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -407,6 +478,7 @@ pub enum WsMsg { ProjectIssuesLoaded(Vec), ProjectUsersRequest, ProjectUsersLoaded(Vec), + ProjectUpdateRequest(UpdateProjectPayload), // issue IssueUpdateRequest(IssueId, UpdateIssuePayload), diff --git a/jirs-data/src/sql.rs b/jirs-data/src/sql.rs index 0bffbd78..b08216e6 100644 --- a/jirs-data/src/sql.rs +++ b/jirs-data/src/sql.rs @@ -2,7 +2,7 @@ use std::io::Write; use diesel::{deserialize::*, pg::*, serialize::*, *}; -use crate::{IssuePriority, IssueStatus, IssueType}; +use crate::{IssuePriority, IssueStatus, IssueType, ProjectCategory}; #[derive(SqlType)] #[postgres(type_name = "IssuePriorityType")] @@ -121,3 +121,45 @@ impl ToSql for IssueStatus { 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 { + match not_none!(bytes) { + b"software" => Ok(ProjectCategory::Software), + b"marketing" => Ok(ProjectCategory::Marketing), + b"business" => Ok(ProjectCategory::Business), + _ => Ok(ProjectCategory::Software), + } +} + +impl FromSql for ProjectCategory { + fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result { + project_category_from_sql(bytes) + } +} + +impl FromSql for ProjectCategory { + fn from_sql(bytes: Option<&[u8]>) -> deserialize::Result { + project_category_from_sql(bytes) + } +} + +impl ToSql for ProjectCategory { + fn to_sql(&self, out: &mut Output) -> 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) + } +} diff --git a/jirs-server/migrations/2020-04-14-090059_change_project_type_field/down.sql b/jirs-server/migrations/2020-04-14-090059_change_project_type_field/down.sql new file mode 100644 index 00000000..91c99553 --- /dev/null +++ b/jirs-server/migrations/2020-04-14-090059_change_project_type_field/down.sql @@ -0,0 +1 @@ +ALTER TABLE projects ALTER COLUMN category SET DATA TYPE VARCHAR; diff --git a/jirs-server/migrations/2020-04-14-090059_change_project_type_field/up.sql b/jirs-server/migrations/2020-04-14-090059_change_project_type_field/up.sql new file mode 100644 index 00000000..229ac69a --- /dev/null +++ b/jirs-server/migrations/2020-04-14-090059_change_project_type_field/up.sql @@ -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'; diff --git a/jirs-server/src/db/issues.rs b/jirs-server/src/db/issues.rs index 58a0caf6..4ddb08c7 100644 --- a/jirs-server/src/db/issues.rs +++ b/jirs-server/src/db/issues.rs @@ -77,11 +77,11 @@ pub struct UpdateIssue { pub status: Option, pub priority: Option, pub list_position: Option, - pub description: Option>, - pub description_text: Option>, - pub estimate: Option>, - pub time_spent: Option>, - pub time_remaining: Option>, + pub description: Option, + pub description_text: Option, + pub estimate: Option, + pub time_spent: Option, + pub time_remaining: Option, pub project_id: Option, pub user_ids: Option>, pub reporter_id: Option, diff --git a/jirs-server/src/db/mod.rs b/jirs-server/src/db/mod.rs index 2dc7299f..2c4deb7e 100644 --- a/jirs-server/src/db/mod.rs +++ b/jirs-server/src/db/mod.rs @@ -23,8 +23,8 @@ impl Actor for DbExecutor { type Context = SyncContext; } -impl DbExecutor { - pub fn new() -> Self { +impl Default for DbExecutor { + fn default() -> Self { Self(build_pool()) } } @@ -37,7 +37,7 @@ pub fn build_pool() -> DbPool { let manager = ConnectionManager::::new(database_url.clone()); #[cfg(debug_assertions)] let manager: ConnectionManager = - ConnectionManager::::new(database_url.clone()); + ConnectionManager::::new(database_url.as_str()); r2d2::Pool::builder() .build(manager) .unwrap_or_else(|e| panic!("Failed to create pool. {}", e)) diff --git a/jirs-server/src/db/projects.rs b/jirs-server/src/db/projects.rs index 8ba9471e..c5a130cd 100644 --- a/jirs-server/src/db/projects.rs +++ b/jirs-server/src/db/projects.rs @@ -2,6 +2,8 @@ use actix::{Handler, Message}; use diesel::prelude::*; use serde::{Deserialize, Serialize}; +use jirs_data::ProjectCategory; + use crate::db::DbExecutor; use crate::errors::ServiceErrors; use crate::models::Project; @@ -25,8 +27,14 @@ impl Handler for DbExecutor { .get() .map_err(|_| ServiceErrors::DatabaseConnectionLost)?; - projects - .filter(id.eq(msg.project_id)) + let query = projects.filter(id.eq(msg.project_id)); + + debug!( + "{}", + diesel::debug_query::(&query).to_string() + ); + + query .first::(conn) .map_err(|_| ServiceErrors::RecordNotFound("Project".to_string())) } @@ -38,7 +46,7 @@ pub struct UpdateProject { pub name: Option, pub url: Option, pub description: Option, - pub category: Option, + pub category: Option, } impl Message for UpdateProject { diff --git a/jirs-server/src/errors.rs b/jirs-server/src/errors.rs index 48a18960..416afb38 100644 --- a/jirs-server/src/errors.rs +++ b/jirs-server/src/errors.rs @@ -32,7 +32,7 @@ impl Into for ServiceErrors { } ServiceErrors::DatabaseQueryFailed(error) => { HttpResponse::BadRequest().json(ErrorResponse { - errors: vec![error.to_owned()], + errors: vec![error], }) } ServiceErrors::RecordNotFound(resource_name) => { diff --git a/jirs-server/src/main.rs b/jirs-server/src/main.rs index ef89e913..a4f59e25 100644 --- a/jirs-server/src/main.rs +++ b/jirs-server/src/main.rs @@ -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 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 || { App::new() diff --git a/jirs-server/src/middleware/authorize.rs b/jirs-server/src/middleware/authorize.rs index 6a93da98..41e96d10 100644 --- a/jirs-server/src/middleware/authorize.rs +++ b/jirs-server/src/middleware/authorize.rs @@ -1,10 +1,12 @@ -use crate::db::SyncQuery; +use std::task::{Context, Poll}; + use actix_service::{Service, Transform}; use actix_web::http::header::{self}; use actix_web::http::HeaderMap; use actix_web::{dev::ServiceRequest, dev::ServiceResponse, Error}; use futures::future::{ok, FutureExt, LocalBoxFuture, Ready}; -use std::task::{Context, Poll}; + +use crate::db::SyncQuery; type Db = actix_web::web::Data; @@ -82,7 +84,7 @@ pub fn token_from_headers( headers: &HeaderMap, ) -> std::result::Result { headers - .get(&header::AUTHORIZATION) + .get(header::AUTHORIZATION) .ok_or_else(|| crate::errors::ServiceErrors::Unauthorized) .map(|h| h.to_str().unwrap_or_default()) .and_then(|s| parse_bearer(s)) diff --git a/jirs-server/src/models.rs b/jirs-server/src/models.rs index 1c5d8236..ed9a1084 100644 --- a/jirs-server/src/models.rs +++ b/jirs-server/src/models.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use jirs_data::{IssuePriority, IssueStatus, IssueType}; +use jirs_data::{IssuePriority, IssueStatus, IssueType, ProjectCategory}; use crate::schema::*; @@ -127,7 +127,7 @@ pub struct Project { pub name: String, pub url: String, pub description: String, - pub category: String, + pub category: ProjectCategory, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } @@ -147,17 +147,15 @@ impl Into for Project { } #[derive(Debug, Serialize, Deserialize, Insertable)] -#[serde(rename_all = "camelCase")] #[table_name = "projects"] pub struct UpdateProjectForm { pub name: Option, pub url: Option, pub description: Option, - pub category: Option, + pub category: Option, } #[derive(Debug, Serialize, Deserialize, Queryable)] -#[serde(rename_all = "camelCase")] pub struct User { pub id: i32, pub name: String, @@ -190,8 +188,8 @@ impl Into for &User { email: self.email.clone(), avatar_url: self.avatar_url.clone(), project_id: self.project_id, - created_at: self.created_at.clone(), - updated_at: self.updated_at.clone(), + created_at: self.created_at, + updated_at: self.updated_at, } } } diff --git a/jirs-server/src/schema.rs b/jirs-server/src/schema.rs index 20991a7f..3b9c1dca 100644 --- a/jirs-server/src/schema.rs +++ b/jirs-server/src/schema.rs @@ -223,10 +223,10 @@ table! { description -> Text, /// The `category` column of the `projects` table. /// - /// Its SQL type is `Text`. + /// Its SQL type is `ProjectCategoryType`. /// /// (Automatically generated by Diesel.) - category -> Text, + category -> ProjectCategoryType, /// The `created_at` column of the `projects` table. /// /// Its SQL type is `Timestamp`. diff --git a/jirs-server/src/ws/issues.rs b/jirs-server/src/ws/issues.rs index e90837a0..5554474e 100644 --- a/jirs-server/src/ws/issues.rs +++ b/jirs-server/src/ws/issues.rs @@ -25,11 +25,11 @@ pub async fn update_issue( status: Some(payload.status), priority: Some(payload.priority), list_position: Some(payload.list_position), - description: Some(payload.description), - description_text: Some(payload.description_text), - estimate: Some(payload.estimate), - time_spent: Some(payload.time_spent), - time_remaining: Some(payload.time_remaining), + description: payload.description, + description_text: payload.description_text, + estimate: payload.estimate, + time_spent: payload.time_spent, + time_remaining: payload.time_remaining, project_id: Some(payload.project_id), user_ids: Some(payload.user_ids), reporter_id: Some(payload.reporter_id), @@ -40,12 +40,7 @@ pub async fn update_issue( _ => return Ok(None), }; - let assignees = match db - .send(LoadAssignees { - issue_id: issue.id.clone(), - }) - .await - { + let assignees = match db.send(LoadAssignees { issue_id: issue.id }).await { Ok(Ok(v)) => v, _ => vec![], }; @@ -53,7 +48,7 @@ pub async fn update_issue( issue.user_ids.push(assignee.user_id); } - Ok(Some(WsMsg::IssueUpdated(issue.into()))) + Ok(Some(WsMsg::IssueUpdated(issue))) } pub async fn add_issue( @@ -109,22 +104,17 @@ pub async fn load_issues(db: &Data>, user: &Option { - for assignee in assignees { - if let Some(issue) = issue_map.get_mut(&assignee.issue_id) { - issue.user_ids.push(assignee.user_id); - } + if let Ok(Ok(assignees)) = f.await { + for assignee in assignees { + if let Some(issue) = issue_map.get_mut(&assignee.issue_id) { + issue.user_ids.push(assignee.user_id); } } - _ => {} }; } let mut issues = vec![]; diff --git a/jirs-server/src/ws/mod.rs b/jirs-server/src/ws/mod.rs index c05f032d..f2629741 100644 --- a/jirs-server/src/ws/mod.rs +++ b/jirs-server/src/ws/mod.rs @@ -75,6 +75,12 @@ impl WebSocketActor { 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 WsMsg::AuthorizeRequest(uuid) => block_on(self.authorize(uuid))?, @@ -114,6 +120,9 @@ impl WebSocketActor { None } }; + if msg.is_some() && msg != Some(WsMsg::Pong) { + info!("sending message {:?}", msg); + } Ok(msg) } diff --git a/jirs-server/src/ws/projects.rs b/jirs-server/src/ws/projects.rs index e4bcf652..f579bf6b 100644 --- a/jirs-server/src/ws/projects.rs +++ b/jirs-server/src/ws/projects.rs @@ -1,9 +1,9 @@ use actix::Addr; 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::ws::{current_user, WsResult}; @@ -13,11 +13,38 @@ pub async fn current_project( ) -> WsResult { let project_id = current_user(user).map(|u| u.project_id)?; - let m = match db.send(LoadProjectUsers { project_id }).await { - Ok(Ok(v)) => Some(WsMsg::ProjectUsersLoaded( - v.into_iter().map(|i| i.into()).collect(), - )), - _ => None, + let m = match db.send(LoadCurrentProject { project_id }).await { + Ok(Ok(project)) => Some(WsMsg::ProjectLoaded(project.into())), + Ok(Err(e)) => { + error!("{:?}", e); + None + } + Err(e) => { + error!("{:?}", e); + None + } }; Ok(m) } + +pub async fn update_project( + db: &Data>, + user: &Option, + 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()))) +} diff --git a/react-client/src/Project/Styles.js b/react-client/src/Project/Styles.js index a79fa9d4..879b9d32 100644 --- a/react-client/src/Project/Styles.js +++ b/react-client/src/Project/Styles.js @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { sizes } from 'shared/utils/styles'; +import { sizes } from '../shared/utils/styles'; const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40; diff --git a/react-client/src/Project/index.jsx b/react-client/src/Project/index.jsx index 2e43e92c..1f8f32a2 100644 --- a/react-client/src/Project/index.jsx +++ b/react-client/src/Project/index.jsx @@ -21,79 +21,79 @@ const Project = () => { const issueSearchModalHelpers = createQueryParamModalHelpers('issue-search'); const issueCreateModalHelpers = createQueryParamModalHelpers('issue-create'); - const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project'); + const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project'); - if (!data) return ; - if (error) return ; + if (!data) return ; + if (error) return ; - const { project } = data; + const { project } = data; - const updateLocalProjectIssues = (issueId, updatedFields) => { - setLocalData(currentData => ({ - project: { - ...currentData.project, - issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields), - }, - })); - }; + const updateLocalProjectIssues = (issueId, updatedFields) => { + setLocalData(currentData => ({ + project: { + ...currentData.project, + issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields), + }, + })); + }; - return ( - - - - - - { issueSearchModalHelpers.isOpen() && ( - } - /> - )} - - {issueCreateModalHelpers.isOpen() && ( - ( - history.push(`${match.url}/board`)} - modalClose={modal.close} + return ( + + - )} - /> - )} - ( - - )} - /> + - } - /> + {issueSearchModalHelpers.isOpen() && ( + } + /> + )} - {match.isExact && } - - ); + {issueCreateModalHelpers.isOpen() && ( + ( + history.push(`${match.url}/board`)} + modalClose={modal.close} + /> + )} + /> + )} + + ( + + )} + /> + + } + /> + + {match.isExact && } + + ); }; export default Project;