diff --git a/jirs-client/Cargo.toml b/jirs-client/Cargo.toml index e1a8c484..680d90b1 100644 --- a/jirs-client/Cargo.toml +++ b/jirs-client/Cargo.toml @@ -31,18 +31,26 @@ version = "*" [dependencies.web-sys] version = "0.3.22" features = [ + # elements "Window", - "DataTransfer", - "DragEvent", "HtmlDivElement", - "DomRect", "HtmlDocument", "Document", + "HtmlBodyElement", + # types + "DataTransfer", + "DomRect", "Selection", "CssStyleDeclaration", "WebSocket", "BinaryType", "Blob", - "MessageEvent", + "AddEventListenerOptions", + # events + "EventTarget", "ErrorEvent", + "MessageEvent", + "KeyEvent", + "KeyboardEvent", + "DragEvent", ] diff --git a/jirs-client/js/css/issue.css b/jirs-client/js/css/issue.css index 49afa05f..84d29e35 100644 --- a/jirs-client/js/css/issue.css +++ b/jirs-client/js/css/issue.css @@ -34,6 +34,71 @@ background: var(--backgroundLight); } +.issueDetails > .content > .left > .comments { + padding-top: 40px; +} + +.issueDetails > .content > .left > .comments > .title { + font-family: var(--font-medium); + font-weight: normal; + font-size: 15px +} + +.issueDetails > .content > .left > .comments > .create { + position: relative; + margin-top: 25px; + font-size: 15px +} + +.issueDetails > .content > .left > .comments > .create > .userAvatar { + position: absolute; + top: 0; + left: 0; +} + +.issueDetails > .content > .left > .comments > .create > .right { + padding-left: 44px; +} + +.issueDetails > .content > .left > .comments > .create > .right > .fakeTextArea { + padding: 12px 16px; + border-radius: 4px; + border: 1px solid var(--borderLightest); + color: var(--textLight); + cursor: pointer; + user-select: none; +} + +.issueDetails > .content > .left > .comments > .create > .right > .fakeTextArea:hover { + border: 1px solid var(--borderLight); +} + +.issueDetails > .content > .left > .comments > .create > .right > .proTip { + display: flex; + align-items: center; + padding-top: 8px; + color: var(--textMedium); + font-size: 13px; +} + +.issueDetails > .content > .left > .comments > .create > .right > .proTip > .strong { + padding-right: 4px; +} + +.issueDetails > .content > .left > .comments > .create > .right > .proTip > .tipLetter { + position: relative; + top: 1px; + display: inline-block; + margin: 0 4px; + padding: 0 4px; + border-radius: 2px; + color: var(--textDarkest); + background: var(--backgroundMedium); + font-family: var(--font-bold); + font-weight: normal; + font-size: 12px +} + .issueDetails > .content > .right { width: 35%; padding-top: 5px; diff --git a/jirs-client/js/css/styledSelect.css b/jirs-client/js/css/styledSelect.css index caea63fa..08bb2206 100644 --- a/jirs-client/js/css/styledSelect.css +++ b/jirs-client/js/css/styledSelect.css @@ -197,58 +197,3 @@ vertical-align: middle; font-size: 14px; } - -.styledSelect > .dropDown > .options > .option > .optionItem { - display: flex; - align-items: center; -} - -.styledSelect > .dropDown > .options > .option > .optionItem > .styledIcon { - font-size: 18px; -} - -.styledSelect > .dropDown > .options > .option > .optionItem > .optionLabel { - padding: 0 5px 0 7px; - font-size: 15px; -} - -/* issue priority */ - -.styledSelect > .dropDown > .options > .option > .optionItem.priority > .optionLabel { - padding: 0 5px 0 7px; - font-size: 15px; - text-transform: capitalize; -} - -.styledSelect > .valueContainer > .selectItem.priority > .selectItemLabel { - text-transform: capitalize; -} - -.styledSelect > .dropDown > .options > .option > .optionItem.priority > .styledIcon { - font-size: 18px; -} - -.styledSelect > .valueContainer > .selectItem.priority.highest > .styledIcon, -.styledSelect > .dropDown > .options > .option > .optionItem.priority.highest > .styledIcon { - color: var(--highest); -} - -.styledSelect > .valueContainer > .selectItem.priority.high > .styledIcon, -.styledSelect > .dropDown > .options > .option > .optionItem.priority.high > .styledIcon { - color: var(--high); -} - -.styledSelect > .valueContainer > .selectItem.priority.medium > .styledIcon, -.styledSelect > .dropDown > .options > .option > .optionItem.priority.medium > .styledIcon { - color: var(--medium); -} - -.styledSelect > .valueContainer > .selectItem.priority.low > .styledIcon, -.styledSelect > .dropDown > .options > .option > .optionItem.priority.low > .styledIcon { - color: var(--low); -} - -.styledSelect > .valueContainer > .selectItem.priority.lowest > .styledIcon, -.styledSelect > .dropDown > .options > .option > .optionItem.priority.lowest > .styledIcon { - color: var(--lowest); -} diff --git a/jirs-client/js/css/styledSelectChild.css b/jirs-client/js/css/styledSelectChild.css new file mode 100644 index 00000000..5d1f59f5 --- /dev/null +++ b/jirs-client/js/css/styledSelectChild.css @@ -0,0 +1,67 @@ +.selectItem { + padding: 4px 8px; +} + +.selectItem.priority.highest > .styledIcon, +.optionItem.priority.highest > .styledIcon { + color: var(--highest); +} + +.selectItem.priority.high > .styledIcon, +.optionItem.priority.high > .styledIcon { + color: var(--high); +} + +.selectItem.priority.medium > .styledIcon, +.optionItem.priority.medium > .styledIcon { + color: var(--medium); +} + +.selectItem.priority.low > .styledIcon, +.optionItem.priority.low > .styledIcon { + color: var(--low); +} + +.selectItem.priority.lowest > .styledIcon, +.optionItem.priority.lowest > .styledIcon { + color: var(--lowest); +} + +.selectItem.priority > .selectItemLabel { + text-transform: capitalize; +} + +.optionItem { + display: flex; + align-items: center; +} + +.optionItem > .styledIcon { + font-size: 18px; +} + +.selectItem > .selectItemLabel, +.optionItem > .optionLabel { + padding: 0 5px 0 7px; + font-size: 15px; +} + +.optionItem.priority > .optionLabel { + padding: 0 5px 0 7px; + font-size: 15px; + text-transform: capitalize; +} + +.optionItem.priority > .styledIcon { + font-size: 18px; +} + +/* edit issue */ +.topActions > .styledSelect > .valueContainer, +.topActions > .styledSelect > .valueContainer > .selectItem { + height: 100%; +} + +.topActions .selectItem, .topActions .optionItem { + padding: 0 12px; +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index f2c30e80..f9baf5b9 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -9,6 +9,7 @@ @import "./css/styledTooltip.css"; @import "./css/styledAvatar.css"; @import "./css/styledSelect.css"; +@import "./css/styledSelectChild.css"; @import "./css/styledButton.css"; @import "./css/styledInput.css"; @import "./css/styledModal.css"; diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 40a6c243..db153177 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -1,9 +1,8 @@ use std::sync::RwLock; -use seed::fetch::FetchObject; use seed::{prelude::*, *}; -use jirs_data::{IssueStatus, WsMsg}; +use jirs_data::*; use crate::api::send_ws_msg; use crate::model::{ModalType, Model, Page}; @@ -21,70 +20,91 @@ mod register; mod shared; mod ws; -pub type UserId = i32; -pub type IssueId = i32; pub type AvatarFilterActive = bool; +pub type AppType = App>; + +#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] +pub enum EditIssueModalFieldId { + IssueType, + Title, + Description, + Status, + Assignees, + Reporter, + Priority, + Estimate, + TimeSpend, + TimeRemaining, + // comment + CommentBody, +} + +#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] +pub enum AddIssueModalFieldId { + IssueType, + Summary, + Description, + Reporter, + Assignees, + Priority, +} #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] pub enum FieldId { - // edit issue - IssueTypeEditModalTop, - TitleIssueEditModal, - DescriptionIssueEditModal, - StatusIssueEditModal, - AssigneesIssueEditModal, - ReporterIssueEditModal, - PriorityIssueEditModal, - EstimateIssueEditModal, - TimeSpendIssueEditModal, - TimeRemainingIssueEditModal, + // issue + AddIssueModal(AddIssueModalFieldId), + EditIssueModal(EditIssueModalFieldId), // project boards TextFilterBoard, - // CopyButtonLabel, - // add issue - IssueTypeAddIssueModal, - SummaryAddIssueModal, - DescriptionAddIssueModal, - ReporterAddIssueModal, - AssigneesAddIssueModal, - IssuePriorityAddIssueModal, } impl std::fmt::Display for FieldId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - FieldId::IssueTypeEditModalTop => f.write_str("issueTypeEditModalTop"), + FieldId::EditIssueModal(sub) => match sub { + EditIssueModalFieldId::IssueType => f.write_str("issueTypeEditModalTop"), + EditIssueModalFieldId::Title => f.write_str("titleIssueEditModal"), + EditIssueModalFieldId::Description => f.write_str("descriptionIssueEditModal"), + EditIssueModalFieldId::Status => f.write_str("statusIssueEditModal"), + EditIssueModalFieldId::Assignees => f.write_str("assigneesIssueEditModal"), + EditIssueModalFieldId::Reporter => f.write_str("reporterIssueEditModal"), + EditIssueModalFieldId::Priority => f.write_str("priorityIssueEditModal"), + EditIssueModalFieldId::Estimate => f.write_str("estimateIssueEditModal"), + EditIssueModalFieldId::TimeSpend => f.write_str("timeSpendIssueEditModal"), + EditIssueModalFieldId::TimeRemaining => f.write_str("timeRemainingIssueEditModal"), + EditIssueModalFieldId::CommentBody => f.write_str("editIssue-commentBody"), + }, + FieldId::AddIssueModal(sub) => match sub { + AddIssueModalFieldId::IssueType => f.write_str("issueTypeAddIssueModal"), + AddIssueModalFieldId::Summary => f.write_str("summaryAddIssueModal"), + AddIssueModalFieldId::Description => f.write_str("descriptionAddIssueModal"), + AddIssueModalFieldId::Reporter => f.write_str("reporterAddIssueModal"), + AddIssueModalFieldId::Assignees => f.write_str("assigneesAddIssueModal"), + AddIssueModalFieldId::Priority => f.write_str("issuePriorityAddIssueModal"), + }, FieldId::TextFilterBoard => f.write_str("textFilterBoard"), FieldId::CopyButtonLabel => f.write_str("copyButtonLabel"), - FieldId::IssueTypeAddIssueModal => f.write_str("issueTypeAddIssueModal"), - FieldId::SummaryAddIssueModal => f.write_str("summaryAddIssueModal"), - FieldId::DescriptionAddIssueModal => f.write_str("descriptionAddIssueModal"), - FieldId::ReporterAddIssueModal => f.write_str("reporterAddIssueModal"), - FieldId::AssigneesAddIssueModal => f.write_str("assigneesAddIssueModal"), - FieldId::IssuePriorityAddIssueModal => f.write_str("issuePriorityAddIssueModal"), - FieldId::TitleIssueEditModal => f.write_str("titleIssueEditModal"), - FieldId::DescriptionIssueEditModal => f.write_str("descriptionIssueEditModal"), - FieldId::StatusIssueEditModal => f.write_str("statusIssueEditModal"), - FieldId::AssigneesIssueEditModal => f.write_str("assigneesIssueEditModal"), - FieldId::ReporterIssueEditModal => f.write_str("reporterIssueEditModal"), - FieldId::PriorityIssueEditModal => f.write_str("priorityIssueEditModal"), - FieldId::EstimateIssueEditModal => f.write_str("estimateIssueEditModal"), - FieldId::TimeSpendIssueEditModal => f.write_str("timeSpendIssueEditModal"), - FieldId::TimeRemainingIssueEditModal => f.write_str("timeRemainingIssueEditModal"), } } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum FieldChange { LinkCopied(FieldId, bool), TabChanged(FieldId, TabMode), + ToggleCreateComment(FieldId, bool), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum Msg { NoOp, + GlobalKeyDown { + key: String, + shift: bool, + ctrl: bool, + alt: bool, + }, // Auth Token AuthTokenStored, @@ -93,7 +113,6 @@ pub enum Msg { StyledSelectChanged(FieldId, StyledSelectChange), ChangePage(model::Page), - CurrentProjectResult(FetchObject), InternalFailure(String), ToggleAboutTooltip, @@ -127,6 +146,9 @@ pub enum Msg { } fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) { + if msg == Msg::NoOp { + return; + } if cfg!(debug_assertions) { log!(msg); } @@ -227,6 +249,28 @@ pub fn render() { 5000, ); + if let Some(body) = seed::html_document().body() { + use wasm_bindgen::JsCast; + + let body = body.dyn_ref::().unwrap().clone(); + let key_up_closure = + wasm_bindgen::closure::Closure::wrap(Box::new(|event: web_sys::KeyboardEvent| { + if let Some(Ok(app)) = unsafe { APP.as_mut().map(|app| app.write()) } { + let msg = Msg::GlobalKeyDown { + key: event.key(), + shift: event.shift_key(), + ctrl: event.ctrl_key(), + alt: event.alt_key(), + }; + app.update(msg); + } + }) + as Box); + body.add_event_listener_with_callback("keyup", key_up_closure.as_ref().unchecked_ref()) + .unwrap(); + key_up_closure.forget(); + } + let app = seed::App::builder(update, view) .routes(routes) .build_and_start(); diff --git a/jirs-client/src/modal/add_issue.rs b/jirs-client/src/modal/add_issue.rs index a6da37e7..71e86193 100644 --- a/jirs-client/src/modal/add_issue.rs +++ b/jirs-client/src/modal/add_issue.rs @@ -1,21 +1,20 @@ use seed::{prelude::*, *}; -use jirs_data::{IssuePriority, IssueType, User}; +use jirs_data::{IssuePriority, IssueType, ToVec}; use crate::api::send_ws_msg; use crate::model::{AddIssueModal, ModalType, Model}; -use crate::shared::styled_avatar::StyledAvatar; use crate::shared::styled_button::StyledButton; use crate::shared::styled_field::StyledField; use crate::shared::styled_form::StyledForm; -use crate::shared::styled_icon::StyledIcon; 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::{FieldId, Msg}; +use crate::{AddIssueModalFieldId, FieldId, Msg}; pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { let modal = model.modals.iter_mut().find(|modal| match modal { @@ -58,16 +57,16 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde orders.skip().send_msg(Msg::ModalDropped); } - Msg::InputChanged(FieldId::DescriptionAddIssueModal, value) => { + Msg::InputChanged(FieldId::AddIssueModal(AddIssueModalFieldId::Description), value) => { modal.description = Some(value.clone()); } - Msg::InputChanged(FieldId::SummaryAddIssueModal, value) => { + Msg::InputChanged(FieldId::AddIssueModal(AddIssueModalFieldId::Summary), value) => { modal.title = value.clone(); } // IssueTypeAddIssueModal Msg::StyledSelectChanged( - FieldId::IssueTypeAddIssueModal, + FieldId::AddIssueModal(AddIssueModalFieldId::IssueType), StyledSelectChange::Changed(id), ) => { modal.issue_type = (*id).into(); @@ -75,7 +74,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde // ReporterAddIssueModal Msg::StyledSelectChanged( - FieldId::ReporterAddIssueModal, + FieldId::AddIssueModal(AddIssueModalFieldId::Reporter), StyledSelectChange::Changed(id), ) => { modal.reporter_id = Some(*id as i32); @@ -83,7 +82,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde // AssigneesAddIssueModal Msg::StyledSelectChanged( - FieldId::AssigneesAddIssueModal, + FieldId::AddIssueModal(AddIssueModalFieldId::Assignees), StyledSelectChange::Changed(id), ) => { let id = *id as i32; @@ -92,7 +91,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde } } Msg::StyledSelectChanged( - FieldId::AssigneesAddIssueModal, + FieldId::AddIssueModal(AddIssueModalFieldId::Assignees), StyledSelectChange::RemoveMulti(id), ) => { let id = *id as i32; @@ -107,7 +106,7 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde // IssuePriorityAddIssueModal Msg::StyledSelectChanged( - FieldId::IssuePriorityAddIssueModal, + FieldId::AddIssueModal(AddIssueModalFieldId::Priority), StyledSelectChange::Changed(id), ) => { modal.priority = (*id).into(); @@ -118,18 +117,19 @@ pub fn update(msg: &Msg, model: &mut crate::model::Model, orders: &mut impl Orde } pub fn view(model: &Model, modal: &AddIssueModal) -> Node { - let select_type = StyledSelect::build(FieldId::IssueTypeAddIssueModal) + let select_type = StyledSelect::build(FieldId::AddIssueModal(AddIssueModalFieldId::IssueType)) .name("type") .normal() .text_filter(modal.type_state.text_filter.as_str()) .opened(modal.type_state.opened) .valid(true) - .options(vec![ - IssueTypeOption(IssueType::Story), - IssueTypeOption(IssueType::Task), - IssueTypeOption(IssueType::Bug), - ]) - .selected(vec![IssueTypeOption(modal.issue_type.clone())]) + .options( + IssueType::ordered() + .iter() + .map(|t| t.to_select_child().name("type")) + .collect(), + ) + .selected(vec![modal.issue_type.to_select_child().name("type")]) .build() .into_node(); let issue_type_field = StyledField::build() @@ -139,7 +139,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .build() .into_node(); - let short_summary = StyledInput::build(FieldId::SummaryAddIssueModal) + let short_summary = StyledInput::build(FieldId::AddIssueModal(AddIssueModalFieldId::Summary)) .valid(true) .build() .into_node(); @@ -152,7 +152,7 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { let description = StyledTextarea::build() .height(110) - .build(FieldId::DescriptionAddIssueModal) + .build(FieldId::AddIssueModal(AddIssueModalFieldId::Description)) .into_node(); let description_field = StyledField::build() .label("Description") @@ -165,18 +165,24 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .reporter_id .or_else(|| model.user.as_ref().map(|u| u.id)) .unwrap_or_default(); - let reporter = StyledSelect::build(FieldId::ReporterAddIssueModal) + let reporter = StyledSelect::build(FieldId::AddIssueModal(AddIssueModalFieldId::Reporter)) .normal() .text_filter(modal.reporter_state.text_filter.as_str()) .opened(modal.reporter_state.opened) - .options(model.users.iter().map(|u| UserOption(u)).collect()) + .options( + model + .users + .iter() + .map(|u| u.to_select_child().name("reporter")) + .collect(), + ) .selected( model .users .iter() .filter_map(|user| { if user.id == reporter_id { - Some(UserOption(user)) + Some(user.to_select_child().name("reporter")) } else { None } @@ -193,19 +199,25 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .build() .into_node(); - let assignees = StyledSelect::build(FieldId::AssigneesAddIssueModal) + let assignees = StyledSelect::build(FieldId::AddIssueModal(AddIssueModalFieldId::Assignees)) .normal() .multi() .text_filter(modal.assignees_state.text_filter.as_str()) .opened(modal.assignees_state.opened) - .options(model.users.iter().map(|u| UserOption(u)).collect()) + .options( + model + .users + .iter() + .map(|u| u.to_select_child().name("assignees")) + .collect(), + ) .selected( model .users .iter() .filter_map(|user| { if modal.user_ids.contains(&user.id) { - Some(UserOption(user)) + Some(user.to_select_child().name("assignees")) } else { None } @@ -222,22 +234,22 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .build() .into_node(); - let select_priority = StyledSelect::build(FieldId::IssuePriorityAddIssueModal) - .name("priority") - .normal() - .text_filter(modal.priority_state.text_filter.as_str()) - .opened(modal.priority_state.opened) - .valid(true) - .options(vec![ - IssuePriorityOption(IssuePriority::Highest), - IssuePriorityOption(IssuePriority::High), - IssuePriorityOption(IssuePriority::Medium), - IssuePriorityOption(IssuePriority::Low), - IssuePriorityOption(IssuePriority::Lowest), - ]) - .selected(vec![IssuePriorityOption(modal.priority.clone())]) - .build() - .into_node(); + let select_priority = + StyledSelect::build(FieldId::AddIssueModal(AddIssueModalFieldId::Priority)) + .name("priority") + .normal() + .text_filter(modal.priority_state.text_filter.as_str()) + .opened(modal.priority_state.opened) + .valid(true) + .options( + IssuePriority::ordered() + .iter() + .map(|p| p.to_select_child().name("priority")) + .collect(), + ) + .selected(vec![modal.priority.to_select_child().name("priority")]) + .build() + .into_node(); let issue_priority_field = StyledField::build() .label("Issue Type") .tip("Priority in relation to other issues.") @@ -292,139 +304,3 @@ pub fn view(model: &Model, modal: &AddIssueModal) -> Node { .build() .into_node() } - -#[derive(PartialOrd, PartialEq, Debug)] -pub struct IssueTypeOption(pub IssueType); - -impl crate::shared::styled_select::SelectOption for IssueTypeOption { - fn into_option(self) -> Node { - let name = self.0.to_label().to_owned(); - - let icon = StyledIcon::build(self.0.into()) - .add_class("issueTypeIcon") - .build() - .into_node(); - - div![ - attrs![At::Class => "type optionItem"], - icon, - div![attrs![At::Class => "typeLabel optionLabel"], name] - ] - } - - fn into_value(self) -> Node { - let name = self.0.to_label().to_owned(); - - let type_icon = StyledIcon::build(self.0.into()).build().into_node(); - - div![ - attrs![At::Class => "selectItem"], - type_icon, - div![attrs![At::Class => "selectItemLabel"], name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0 - .to_string() - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.0.clone().into() - } -} - -#[derive(Debug, PartialEq)] -pub struct IssuePriorityOption(IssuePriority); - -impl crate::shared::styled_select::SelectOption for IssuePriorityOption { - fn into_option(self) -> Node { - let name = format!("{}", self.0); - - let icon = StyledIcon::build(self.0.into()) - .add_class("issuePriorityIcon") - .size(18) - .build() - .into_node(); - - div![ - attrs![At::Class => format!("priority optionItem {}", name)], - icon, - div![attrs![At::Class => "priorityLabel optionLabel"], name] - ] - } - - fn into_value(self) -> Node { - let name = format!("{}", self.0); - - let type_icon = StyledIcon::build(self.0.into()).build().into_node(); - - div![ - attrs![At::Class => format!("selectItem priority {}", name)], - type_icon, - div![attrs![At::Class => "selectItemLabel"], name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0 - .to_string() - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.0.clone().into() - } -} - -#[derive(Debug, PartialEq)] -pub struct UserOption<'opt>(pub &'opt User); - -impl<'opt> UserOption<'opt> { - fn avatar_node(&self) -> Node { - let user = self.0; - StyledAvatar::build() - .avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default()) - .size(20) - .name(user.name.as_str()) - .build() - .into_node() - } -} - -impl<'opt> crate::shared::styled_select::SelectOption for UserOption<'opt> { - fn into_option(self) -> Node { - let user = self.0; - - let styled_avatar = self.avatar_node(); - - div![ - attrs![At::Class => "user optionItem"], - styled_avatar, - div![attrs![At::Class => "typeLabel optionLabel"], user.name] - ] - } - - fn into_value(self) -> Node { - let user = self.0; - - let styled_avatar = self.avatar_node(); - - div![ - attrs![At::Class => "selectItem"], - styled_avatar, - div![attrs![At::Class => "selectItemLabel"], user.name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0.name.contains(text_filter) - } - - fn to_value(&self) -> u32 { - self.0.id as u32 - } -} diff --git a/jirs-client/src/modal/issue_details.rs b/jirs-client/src/modal/issue_details.rs index 02100983..9c7c0927 100644 --- a/jirs-client/src/modal/issue_details.rs +++ b/jirs-client/src/modal/issue_details.rs @@ -8,12 +8,13 @@ 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::{SelectOption, StyledSelect, StyledSelectChange}; +use crate::shared::styled_select::{StyledSelect, StyledSelectChange}; +use crate::shared::styled_select_child::ToStyledSelectChild; use crate::shared::styled_textarea::StyledTextarea; use crate::shared::ToNode; -use crate::{FieldChange, FieldId, IssueId, Msg}; +use crate::{EditIssueModalFieldId, FieldChange, FieldId, Msg}; pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { let modal: &mut EditIssueModal = match model.modals.get_mut(0) { @@ -31,35 +32,35 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { modal.payload = issue.clone().into(); } Msg::StyledSelectChanged( - FieldId::IssueTypeEditModalTop, + FieldId::EditIssueModal(EditIssueModalFieldId::IssueType), StyledSelectChange::Changed(value), ) => { modal.payload.issue_type = (*value).into(); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } Msg::StyledSelectChanged( - FieldId::StatusIssueEditModal, + FieldId::EditIssueModal(EditIssueModalFieldId::Status), StyledSelectChange::Changed(value), ) => { modal.payload.status = (*value).into(); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } Msg::StyledSelectChanged( - FieldId::ReporterIssueEditModal, + FieldId::EditIssueModal(EditIssueModalFieldId::Reporter), StyledSelectChange::Changed(value), ) => { modal.payload.reporter_id = *value as i32; send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } Msg::StyledSelectChanged( - FieldId::AssigneesIssueEditModal, + FieldId::EditIssueModal(EditIssueModalFieldId::Assignees), StyledSelectChange::Changed(value), ) => { modal.payload.user_ids.push(*value as i32); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } Msg::StyledSelectChanged( - FieldId::AssigneesIssueEditModal, + FieldId::EditIssueModal(EditIssueModalFieldId::Assignees), StyledSelectChange::RemoveMulti(value), ) => { let mut old = vec![]; @@ -73,61 +74,68 @@ pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } Msg::StyledSelectChanged( - FieldId::PriorityIssueEditModal, + FieldId::EditIssueModal(EditIssueModalFieldId::Priority), StyledSelectChange::Changed(value), ) => { modal.payload.priority = (*value).into(); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } - Msg::InputChanged(FieldId::TitleIssueEditModal, value) => { + Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::Title), value) => { modal.payload.title = value.clone(); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } - Msg::InputChanged(FieldId::DescriptionIssueEditModal, value) => { + Msg::InputChanged(FieldId::EditIssueModal(EditIssueModalFieldId::Description), value) => { modal.payload.description = Some(value.clone()); modal.payload.description_text = Some(value.clone()); send_ws_msg(WsMsg::IssueUpdateRequest(modal.id, modal.payload.clone())); } - Msg::ModalChanged(FieldChange::TabChanged(FieldId::DescriptionIssueEditModal, mode)) => { + Msg::ModalChanged(FieldChange::TabChanged( + FieldId::EditIssueModal(EditIssueModalFieldId::Description), + mode, + )) => { modal.description_editor_mode = mode.clone(); } + Msg::ModalChanged(FieldChange::ToggleCreateComment( + FieldId::EditIssueModal(EditIssueModalFieldId::CommentBody), + flag, + )) => { + modal.creating_comment = *flag; + } + Msg::GlobalKeyDown { key, .. } if key.as_str() == "m" && !modal.creating_comment => { + orders + .skip() + .send_msg(Msg::ModalChanged(FieldChange::ToggleCreateComment( + FieldId::EditIssueModal(EditIssueModalFieldId::CommentBody), + true, + ))); + } _ => (), } } pub fn view(model: &Model, modal: &EditIssueModal) -> Node { + div![ + attrs![At::Class => "issueDetails"], + top_modal_row(model, modal), + div![ + attrs![At::Class => "content"], + left_modal_column(model, modal), + right_modal_column(model, modal), + ], + ] +} + +fn top_modal_row(_model: &Model, modal: &EditIssueModal) -> Node { let EditIssueModal { id, - link_copied, payload, top_type_state, - status_state, - reporter_state: _, - assignees_state: _, - priority_state: _, - description_editor_mode, + link_copied, + .. } = modal; - let issue_id = id.clone(); - let issue_type_select = StyledSelect::build(FieldId::IssueTypeEditModalTop) - .dropdown_width(150) - .name("type") - .text_filter(top_type_state.text_filter.as_str()) - .opened(top_type_state.opened) - .valid(true) - .options( - IssueType::ordered() - .into_iter() - .map(|t| IssueTypeTopOption(issue_id, t)) - .collect(), - ) - .selected(vec![IssueTypeTopOption( - issue_id, - payload.issue_type.clone(), - )]) - .build() - .into_node(); + let issue_id = id.clone(); let click_handler = mouse_ev(Ev::Click, move |_| { use wasm_bindgen::JsCast; @@ -177,26 +185,135 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - // left + let issue_type_select = + StyledSelect::build(FieldId::EditIssueModal(EditIssueModalFieldId::IssueType)) + .dropdown_width(150) + .name("type") + .text_filter(top_type_state.text_filter.as_str()) + .opened(top_type_state.opened) + .valid(true) + .options( + IssueType::ordered() + .into_iter() + .map(|t| t.to_select_child().name("type")) + .collect(), + ) + .selected(vec![{ + let id = modal.id.clone(); + let issue_type = &payload.issue_type; + issue_type + .to_select_child() + .name("type") + .text(format!("{} - {}", issue_type, id)) + }]) + .build() + .into_node(); + + div![ + attrs![At::Class => "topActions"], + issue_type_select, + div![ + attrs![At::Class => "topActionsRight"], + copy_button, + delete_button, + close_button + ], + ] +} + +fn left_modal_column(model: &Model, modal: &EditIssueModal) -> Node { + let EditIssueModal { + payload, + description_editor_mode, + creating_comment, + .. + } = modal; + let title = StyledTextarea::build() .value(payload.title.as_str()) .add_class("textarea") .max_height(48) .height(0) - .build(FieldId::TitleIssueEditModal) + .build(FieldId::EditIssueModal(EditIssueModalFieldId::Title)) .into_node(); let description_text = payload.description.as_ref().cloned().unwrap_or_default(); - let description = StyledEditor::build(FieldId::DescriptionIssueEditModal) - .text(description_text) - .mode(description_editor_mode.clone()) - .update_on(Ev::Change) - .build() - .into_node(); + let description = + StyledEditor::build(FieldId::EditIssueModal(EditIssueModalFieldId::Description)) + .text(description_text) + .mode(description_editor_mode.clone()) + .update_on(Ev::Change) + .build() + .into_node(); let description_field = StyledField::build().input(description).build().into_node(); - // right - let status = StyledSelect::build(FieldId::StatusIssueEditModal) + let user_avatar = StyledAvatar::build() + .add_class("userAvatar") + .size(32) + .avatar_url( + model + .user + .as_ref() + .and_then(|u| u.avatar_url.clone()) + .unwrap_or_default(), + ) + .build() + .into_node(); + + let create_comment = if *creating_comment { + let text_area = StyledTextarea::build() + .build(FieldId::EditIssueModal(EditIssueModalFieldId::CommentBody)) + .into_node(); + div![text_area] + } else { + let creating_comment = *creating_comment; + let handler = mouse_ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ModalChanged(FieldChange::ToggleCreateComment( + FieldId::EditIssueModal(EditIssueModalFieldId::CommentBody), + !creating_comment, + )) + }); + div![class!["fakeTextArea"], handler] + }; + + div![ + class!["left"], + title, + description_field, + div![ + class!["comments"], + div![class!["title"], "Comments"], + div![ + class!["create"], + user_avatar, + div![ + class!["right"], + create_comment, + div![ + class!["proTip"], + strong![class!["strong"], "Pro tip: "], + "press ", + span![class!["tipLetter"], "M"], + " to comment" + ] + ] + ] + ], + ] +} + +fn right_modal_column(model: &Model, modal: &EditIssueModal) -> Node { + let EditIssueModal { + payload, + status_state, + reporter_state, + assignees_state, + priority_state, + .. + } = modal; + + let status = StyledSelect::build(FieldId::EditIssueModal(EditIssueModalFieldId::Status)) .name("status") .opened(status_state.opened) .normal() @@ -204,10 +321,10 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .options( IssueStatus::ordered() .into_iter() - .map(|opt| IssueStatusOption(issue_id, opt)) + .map(|opt| opt.to_select_child().name("status")) .collect(), ) - .selected(vec![IssueStatusOption(issue_id, payload.status.clone())]) + .selected(vec![payload.status.to_select_child().name("status")]) .valid(true) .build() .into_node(); @@ -217,19 +334,25 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - let assignees = StyledSelect::build(FieldId::AssigneesIssueEditModal) + let assignees = StyledSelect::build(FieldId::EditIssueModal(EditIssueModalFieldId::Assignees)) .name("assignees") - .opened(modal.assignees_state.opened) - .normal() + .opened(assignees_state.opened) + .empty() .multi() - .text_filter(modal.assignees_state.text_filter.as_str()) - .options(model.users.iter().map(|user| UserOption(user)).collect()) + .text_filter(assignees_state.text_filter.as_str()) + .options( + model + .users + .iter() + .map(|user| user.to_select_child().name("assignees")) + .collect(), + ) .selected( model .users .iter() .filter(|user| payload.user_ids.contains(&user.id)) - .map(|user| UserOption(user)) + .map(|user| user.to_select_child().name("assignees")) .collect(), ) .build() @@ -240,18 +363,24 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - let reporter = StyledSelect::build(FieldId::ReporterIssueEditModal) + let reporter = StyledSelect::build(FieldId::EditIssueModal(EditIssueModalFieldId::Reporter)) .name("reporter") - .opened(modal.reporter_state.opened) - .normal() - .text_filter(modal.reporter_state.text_filter.as_str()) - .options(model.users.iter().map(|user| UserOption(user)).collect()) + .opened(reporter_state.opened) + .empty() + .text_filter(reporter_state.text_filter.as_str()) + .options( + model + .users + .iter() + .map(|user| user.to_select_child().name("reporter")) + .collect(), + ) .selected( model .users .iter() .filter(|user| payload.reporter_id == user.id) - .map(|user| UserOption(user)) + .map(|user| user.to_select_child().name("reporter")) .collect(), ) .build() @@ -262,18 +391,18 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - let priority = StyledSelect::build(FieldId::PriorityIssueEditModal) - .name("assignees") - .opened(modal.priority_state.opened) - .normal() - .text_filter(modal.priority_state.text_filter.as_str()) + let priority = StyledSelect::build(FieldId::EditIssueModal(EditIssueModalFieldId::Priority)) + .name("priority") + .opened(priority_state.opened) + .empty() + .text_filter(priority_state.text_filter.as_str()) .options( IssuePriority::ordered() .into_iter() - .map(|p| IssuePriorityOption(p)) + .map(|p| p.to_select_child().name("priority")) .collect(), ) - .selected(vec![IssuePriorityOption(payload.priority.clone())]) + .selected(vec![payload.priority.to_select_child().name("priority")]) .build() .into_node(); let priority_field = StyledField::build() @@ -282,7 +411,7 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .build() .into_node(); - let estimate = StyledInput::build(FieldId::EstimateIssueEditModal) + let estimate = StyledInput::build(FieldId::EditIssueModal(EditIssueModalFieldId::Estimate)) .valid(true) .build() .into_node(); @@ -293,246 +422,11 @@ pub fn view(model: &Model, modal: &EditIssueModal) -> Node { .into_node(); div![ - attrs![At::Class => "issueDetails"], - div![ - attrs![At::Class => "topActions"], - issue_type_select, - div![ - attrs![At::Class => "topActionsRight"], - copy_button, - delete_button, - close_button - ], - ], - div![ - attrs![At::Class => "content"], - div![ - attrs![At::Class => "left"], - title, - description_field, - div![attrs![At::Class => "comments"]], - ], - div![ - attrs![At::Class => "right"], - status_field, - assignees_field, - reporter_field, - priority_field, - estimate_field - ], - ], + attrs![At::Class => "right"], + status_field, + assignees_field, + reporter_field, + priority_field, + estimate_field ] } - -#[derive(PartialOrd, PartialEq, Debug)] -pub struct IssueTypeTopOption(pub IssueId, pub IssueType); - -impl SelectOption for IssueTypeTopOption { - fn into_option(self) -> Node { - let name = self.1.to_label().to_owned(); - - let icon = StyledIcon::build(self.1.into()) - .add_class("issueTypeIcon".to_string()) - .build() - .into_node(); - - div![ - attrs![At::Class => "optionItem"], - icon, - div![attrs![At::Class => "optionLabel typeLabel"], name] - ] - } - - fn into_value(self) -> Node { - let issue_id = self.0; - let name = self.1.to_label().to_owned(); - - StyledButton::build() - .empty() - .children(vec![span![format!("{}-{}", name, issue_id)]]) - .icon(StyledIcon::build(self.1.into()).build()) - .build() - .into_node() - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.1 - .to_string() - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.1.clone().into() - } -} - -///// -#[derive(PartialOrd, PartialEq, Debug)] -pub struct IssueStatusOption(pub IssueId, pub IssueStatus); - -impl SelectOption for IssueStatusOption { - fn into_option(self) -> Node { - let name = self.1.to_label().to_owned(); - - div![ - attrs![At::Class => "type optionItem"], - div![attrs![At::Class => "typeLabel optionLabel"], name] - ] - } - - fn into_value(self) -> Node { - let name = self.1.to_label().to_owned(); - - div![ - attrs![At::Class => "selectItem"], - div![attrs![At::Class => "selectItemLabel"], name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - format!("{}", self.0) - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.1.clone().into() - } -} - -#[derive(PartialOrd, PartialEq, Debug)] -pub struct IssueTypeOption(pub IssueType); - -impl SelectOption for IssueTypeOption { - fn into_option(self) -> Node { - let name = self.0.to_label().to_owned(); - - let icon = StyledIcon::build(self.0.into()) - .add_class("issueTypeIcon") - .build() - .into_node(); - - div![ - attrs![At::Class => "type optionItem"], - icon, - div![attrs![At::Class => "typeLabel optionLabel"], name] - ] - } - - fn into_value(self) -> Node { - let name = self.0.to_label().to_owned(); - - let type_icon = StyledIcon::build(self.0.into()).build().into_node(); - - div![ - attrs![At::Class => "selectItem"], - type_icon, - div![attrs![At::Class => "selectItemLabel"], name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0 - .to_string() - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.0.clone().into() - } -} - -#[derive(Debug, PartialEq)] -pub struct IssuePriorityOption(IssuePriority); - -impl SelectOption for IssuePriorityOption { - fn into_option(self) -> Node { - let name = format!("{}", self.0); - - let icon = StyledIcon::build(self.0.into()) - .add_class("issuePriorityIcon") - .size(18) - .build() - .into_node(); - - div![ - attrs![At::Class => format!("priority optionItem {}", name)], - icon, - div![attrs![At::Class => "priorityLabel optionLabel"], name] - ] - } - - fn into_value(self) -> Node { - let name = format!("{}", self.0); - - let type_icon = StyledIcon::build(self.0.into()).build().into_node(); - - div![ - attrs![At::Class => format!("selectItem priority {}", name)], - type_icon, - div![attrs![At::Class => "selectItemLabel"], name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0 - .to_string() - .to_lowercase() - .contains(&text_filter.to_lowercase()) - } - - fn to_value(&self) -> u32 { - self.0.clone().into() - } -} - -#[derive(Debug, PartialEq)] -pub struct UserOption<'opt>(pub &'opt User); - -impl<'opt> UserOption<'opt> { - fn avatar_node(&self) -> Node { - let user = self.0; - StyledAvatar::build() - .avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default()) - .size(20) - .name(user.name.as_str()) - .build() - .into_node() - } -} - -impl<'opt> SelectOption for UserOption<'opt> { - fn into_option(self) -> Node { - let user = self.0; - - let styled_avatar = self.avatar_node(); - - div![ - attrs![At::Class => "user optionItem"], - styled_avatar, - div![attrs![At::Class => "typeLabel optionLabel"], user.name] - ] - } - - fn into_value(self) -> Node { - let user = self.0; - - let styled_avatar = self.avatar_node(); - - div![ - attrs![At::Class => "selectItem"], - styled_avatar, - div![attrs![At::Class => "selectItemLabel"], user.name] - ] - } - - fn match_text_filter(&self, text_filter: &str) -> bool { - self.0.name.contains(text_filter) - } - - fn to_value(&self) -> u32 { - self.0.id as u32 - } -} diff --git a/jirs-client/src/modal/mod.rs b/jirs-client/src/modal/mod.rs index 63c7a5a4..e90f5435 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -1,12 +1,10 @@ use seed::{prelude::*, *}; -use jirs_data::{UpdateIssuePayload, WsMsg}; +use jirs_data::WsMsg; use crate::api::send_ws_msg; use crate::model::{AddIssueModal, EditIssueModal, ModalType, Model, Page}; -use crate::shared::styled_editor::Mode; use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant}; -use crate::shared::styled_select::StyledSelectState; use crate::shared::{find_issue, ToNode}; use crate::{model, FieldChange, FieldId, Msg}; @@ -91,34 +89,7 @@ fn push_edit_modal(issue_id: &i32, model: &mut Model) { Some(issue) => issue, _ => return, }; - ModalType::EditIssue( - *issue_id, - EditIssueModal { - id: *issue_id, - link_copied: false, - payload: UpdateIssuePayload { - title: issue.title.clone(), - issue_type: issue.issue_type.clone(), - status: issue.status.clone(), - priority: issue.priority.clone(), - list_position: issue.list_position.clone(), - 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(), - user_ids: issue.user_ids.clone(), - }, - top_type_state: StyledSelectState::new(FieldId::IssueTypeEditModalTop), - status_state: StyledSelectState::new(FieldId::StatusIssueEditModal), - reporter_state: StyledSelectState::new(FieldId::ReporterIssueEditModal), - assignees_state: StyledSelectState::new(FieldId::AssigneesIssueEditModal), - priority_state: StyledSelectState::new(FieldId::PriorityIssueEditModal), - description_editor_mode: Mode::Editor, - }, - ) + ModalType::EditIssue(*issue_id, EditIssueModal::new(issue)) }; model.modals.push(modal); } diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index 06aaadd0..4426a16e 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -7,9 +7,7 @@ use jirs_data::*; use crate::shared::styled_editor::Mode; use crate::shared::styled_select::StyledSelectState; -use crate::{FieldId, IssueId, UserId, HOST_URL}; - -pub type ProjectId = i32; +use crate::{AddIssueModalFieldId, EditIssueModalFieldId, FieldId, HOST_URL}; #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] pub enum ModalType { @@ -30,6 +28,48 @@ pub struct EditIssueModal { pub priority_state: StyledSelectState, pub description_editor_mode: Mode, + pub creating_comment: bool, +} + +impl EditIssueModal { + pub fn new(issue: &Issue) -> Self { + Self { + id: issue.id, + link_copied: false, + payload: UpdateIssuePayload { + title: issue.title.clone(), + issue_type: issue.issue_type.clone(), + status: issue.status.clone(), + priority: issue.priority.clone(), + list_position: issue.list_position.clone(), + 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(), + user_ids: issue.user_ids.clone(), + }, + top_type_state: StyledSelectState::new(FieldId::EditIssueModal( + EditIssueModalFieldId::IssueType, + )), + status_state: StyledSelectState::new(FieldId::EditIssueModal( + EditIssueModalFieldId::Status, + )), + reporter_state: StyledSelectState::new(FieldId::EditIssueModal( + EditIssueModalFieldId::Reporter, + )), + assignees_state: StyledSelectState::new(FieldId::EditIssueModal( + EditIssueModalFieldId::Assignees, + )), + priority_state: StyledSelectState::new(FieldId::EditIssueModal( + EditIssueModalFieldId::Priority, + )), + description_editor_mode: Mode::Editor, + creating_comment: false, + } + } } #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] @@ -69,10 +109,18 @@ impl Default for AddIssueModal { project_id: Default::default(), user_ids: Default::default(), reporter_id: Default::default(), - type_state: StyledSelectState::new(FieldId::IssueTypeAddIssueModal), - reporter_state: StyledSelectState::new(FieldId::ReporterAddIssueModal), - assignees_state: StyledSelectState::new(FieldId::AssigneesAddIssueModal), - priority_state: StyledSelectState::new(FieldId::IssuePriorityAddIssueModal), + type_state: StyledSelectState::new(FieldId::AddIssueModal( + AddIssueModalFieldId::IssueType, + )), + reporter_state: StyledSelectState::new(FieldId::AddIssueModal( + AddIssueModalFieldId::Reporter, + )), + assignees_state: StyledSelectState::new(FieldId::AddIssueModal( + AddIssueModalFieldId::Assignees, + )), + priority_state: StyledSelectState::new(FieldId::AddIssueModal( + AddIssueModalFieldId::Priority, + )), } } } diff --git a/jirs-client/src/project.rs b/jirs-client/src/project.rs index 7c7841f0..03833d64 100644 --- a/jirs-client/src/project.rs +++ b/jirs-client/src/project.rs @@ -10,7 +10,7 @@ use crate::shared::styled_icon::{Icon, StyledIcon}; use crate::shared::styled_input::StyledInput; use crate::shared::styled_select::StyledSelectChange; use crate::shared::{drag_ev, inner_layout, ToNode}; -use crate::{FieldId, Msg}; +use crate::{EditIssueModalFieldId, FieldId, Msg}; pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Orders) { match msg { @@ -47,7 +47,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order model.project_page.about_tooltip_visible = !model.project_page.about_tooltip_visible; } Msg::StyledSelectChanged( - FieldId::IssueTypeEditModalTop, + FieldId::EditIssueModal(EditIssueModalFieldId::IssueType), StyledSelectChange::Text(text), ) => { let modal = model @@ -289,13 +289,12 @@ fn project_issue(model: &Model, issue: &Issue) -> Node { .iter() .filter(|user| issue.user_ids.contains(&user.id)) .map(|user| { - StyledAvatar { - avatar_url: user.avatar_url.clone(), - size: 24, - name: user.name.clone(), - on_click: None, - } - .into_node() + StyledAvatar::build() + .size(24) + .name(user.name.as_str()) + .avatar_url(user.avatar_url.as_ref().cloned().unwrap_or_default()) + .build() + .into_node() }) .collect(); diff --git a/jirs-client/src/shared/mod.rs b/jirs-client/src/shared/mod.rs index 34031d67..18f527eb 100644 --- a/jirs-client/src/shared/mod.rs +++ b/jirs-client/src/shared/mod.rs @@ -1,10 +1,10 @@ use seed::{prelude::*, *}; use wasm_bindgen::JsCast; -use jirs_data::Issue; +use jirs_data::*; use crate::model::Model; -use crate::{IssueId, Msg}; +use crate::Msg; pub mod aside; pub mod navbar_left; @@ -18,6 +18,7 @@ pub mod styled_icon; pub mod styled_input; pub mod styled_modal; pub mod styled_select; +pub mod styled_select_child; pub mod styled_textarea; pub mod styled_tooltip; diff --git a/jirs-client/src/shared/styled_avatar.rs b/jirs-client/src/shared/styled_avatar.rs index 49a4aed3..2f0caf8e 100644 --- a/jirs-client/src/shared/styled_avatar.rs +++ b/jirs-client/src/shared/styled_avatar.rs @@ -4,10 +4,11 @@ use crate::shared::ToNode; use crate::Msg; pub struct StyledAvatar { - pub avatar_url: Option, - pub size: u32, - pub name: String, - pub on_click: Option>, + avatar_url: Option, + size: u32, + name: String, + on_click: Option>, + class_list: Vec, } impl Default for StyledAvatar { @@ -17,6 +18,7 @@ impl Default for StyledAvatar { size: 32, name: "".to_string(), on_click: None, + class_list: vec![], } } } @@ -28,6 +30,7 @@ impl StyledAvatar { size: None, name: "".to_string(), on_click: None, + class_list: vec![], } } } @@ -39,10 +42,11 @@ impl ToNode for StyledAvatar { } pub struct StyledAvatarBuilder { - pub avatar_url: Option, - pub size: Option, - pub name: String, - pub on_click: Option>, + avatar_url: Option, + size: Option, + name: String, + on_click: Option>, + class_list: Vec, } impl StyledAvatarBuilder { @@ -50,7 +54,10 @@ impl StyledAvatarBuilder { where S: Into, { - self.avatar_url = Some(avatar_url.into()); + let url = avatar_url.into(); + if !url.is_empty() { + self.avatar_url = Some(url); + } self } @@ -72,12 +79,21 @@ impl StyledAvatarBuilder { self } + pub fn add_class(mut self, name: S) -> Self + where + S: Into, + { + self.class_list.push(name.into()); + self + } + pub fn build(self) -> StyledAvatar { StyledAvatar { avatar_url: self.avatar_url, size: self.size.unwrap_or(32), name: self.name, on_click: self.on_click, + class_list: self.class_list, } } } @@ -88,7 +104,15 @@ pub fn render(values: StyledAvatar) -> Node { size, name, on_click, + mut class_list, } = values; + + class_list.push("styledAvatar".to_string()); + match avatar_url { + Some(_) => class_list.push("image".to_string()), + _ => class_list.push("letter".to_string()), + }; + let shared_style = format!("width: {size}px; height: {size}px", size = size); let handler = match on_click { None => vec![], @@ -96,11 +120,11 @@ pub fn render(values: StyledAvatar) -> Node { }; match avatar_url { Some(url) => div![ - attrs![At::Class => "styledAvatar image", At::Style => format!("{shared}; background-image: url({url});", shared = shared_style, url = url)], + attrs![At::Class => class_list.join(" "), At::Style => format!("{shared}; background-image: url({url});", shared = shared_style, url = url)], handler, ], _ => div![ - attrs![At::Class => "styledAvatar letter", At::Style => shared_style], + attrs![At::Class => class_list.join(" "), At::Style => shared_style], span![name], handler ], diff --git a/jirs-client/src/shared/styled_editor.rs b/jirs-client/src/shared/styled_editor.rs index 4bbb8fcb..d6e02c22 100644 --- a/jirs-client/src/shared/styled_editor.rs +++ b/jirs-client/src/shared/styled_editor.rs @@ -28,7 +28,7 @@ impl StyledEditor { StyledEditorBuilder { id, text: String::new(), - mode: Mode::Editor, + mode: Mode::View, update_event: None, } } diff --git a/jirs-client/src/shared/styled_input.rs b/jirs-client/src/shared/styled_input.rs index 8d19d5d8..2e8e9d5b 100644 --- a/jirs-client/src/shared/styled_input.rs +++ b/jirs-client/src/shared/styled_input.rs @@ -74,12 +74,17 @@ pub fn render(values: StyledInput) -> Node { let mut handlers = vec![]; - let input_handler = input_ev(Ev::KeyUp, move |value| Msg::InputChanged(id, value)); - handlers.push(input_handler); + handlers.push(input_ev(Ev::KeyUp, move |value| { + Msg::InputChanged(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(" ")], handlers], ] } diff --git a/jirs-client/src/shared/styled_select.rs b/jirs-client/src/shared/styled_select.rs index dc534cd7..2defcd7f 100644 --- a/jirs-client/src/shared/styled_select.rs +++ b/jirs-client/src/shared/styled_select.rs @@ -1,6 +1,7 @@ use seed::{prelude::*, *}; use crate::shared::styled_icon::{Icon, StyledIcon}; +use crate::shared::styled_select_child::*; use crate::shared::ToNode; use crate::{FieldId, Msg}; @@ -33,16 +34,6 @@ impl std::fmt::Display for Variant { } } -pub trait SelectOption { - fn into_option(self) -> Node; - - fn into_value(self) -> Node; - - fn match_text_filter(&self, text_filter: &str) -> bool; - - fn to_value(&self) -> u32; -} - #[derive(Debug, Clone, PartialOrd, PartialEq, Hash)] pub struct StyledSelectState { pub field_id: FieldId, @@ -79,10 +70,7 @@ impl StyledSelectState { } } -pub struct StyledSelect -where - Child: SelectOption + PartialEq, -{ +pub struct StyledSelect { id: FieldId, variant: Variant, dropdown_width: Option, @@ -90,26 +78,20 @@ where valid: bool, is_multi: bool, allow_clear: bool, - options: Vec, - selected: Vec, + options: Vec, + selected: Vec, text_filter: String, opened: bool, } -impl ToNode for StyledSelect -where - Child: SelectOption + PartialEq, -{ +impl ToNode for StyledSelect { fn into_node(self) -> Node { render(self) } } -impl StyledSelect -where - Child: SelectOption + PartialEq, -{ - pub fn build(id: FieldId) -> StyledSelectBuilder { +impl StyledSelect { + pub fn build(id: FieldId) -> StyledSelectBuilder { StyledSelectBuilder { id, variant: None, @@ -127,10 +109,7 @@ where } #[derive(Debug)] -pub struct StyledSelectBuilder -where - Child: SelectOption + PartialEq, -{ +pub struct StyledSelectBuilder { id: FieldId, variant: Option, dropdown_width: Option>, @@ -138,17 +117,14 @@ where valid: Option, is_multi: Option, allow_clear: Option, - options: Option>, - selected: Option>, + options: Option>, + selected: Option>, text_filter: Option, opened: Option, } -impl StyledSelectBuilder -where - Child: SelectOption + PartialEq, -{ - pub fn build(self) -> StyledSelect { +impl StyledSelectBuilder { + pub fn build(self) -> StyledSelect { StyledSelect { id: self.id, variant: self.variant.unwrap_or_default(), @@ -195,12 +171,12 @@ where self } - pub fn options(mut self, options: Vec) -> Self { + pub fn options(mut self, options: Vec) -> Self { self.options = Some(options); self } - pub fn selected(mut self, selected: Vec) -> Self { + pub fn selected(mut self, selected: Vec) -> Self { self.selected = Some(selected); self } @@ -210,16 +186,18 @@ where self } + pub fn empty(mut self) -> Self { + self.variant = Some(Variant::Empty); + self + } + pub fn multi(mut self) -> Self { self.is_multi = Some(true); self } } -pub fn render(values: StyledSelect) -> Node -where - Child: SelectOption + PartialEq, -{ +pub fn render(values: StyledSelect) -> Node { let StyledSelect { id, variant, @@ -247,6 +225,7 @@ where let dropdown_style = dropdown_width .map(|n| format!("width: {}px;", n)) .unwrap_or_else(|| format!("width: 100%;")); + let mut select_class = vec!["styledSelect".to_string(), format!("{}", variant)]; if !valid { select_class.push("invalid".to_string()); @@ -262,9 +241,11 @@ where let children: Vec> = options .into_iter() - .filter(|o| !selected.contains(&o) && o.match_text_filter(text_filter.as_str())) + .filter(|o| !selected.contains(&o) && o.match_text(text_filter.as_str())) .map(|child| { - let value = child.to_value(); + let child = child.build(DisplayType::SelectOption); + let value = child.value(); + let node = child.into_node(); let field_id = id.clone(); let on_change = mouse_ev(Ev::Click, move |_| { Msg::StyledSelectChanged(field_id, StyledSelectChange::Changed(value)) @@ -273,7 +254,7 @@ where attrs![At::Class => "option"], on_change, visibility_handler.clone(), - child.into_option() + node ] }) .collect(); @@ -320,12 +301,16 @@ where } else { selected .into_iter() - .map(|m| render_value(m.into_value())) + .map(|m| render_value(m.build(DisplayType::SelectValue).into_node())) .collect() }; seed::div![ - attrs![At::Class => select_class.join(" ")], + attrs![At::Class => select_class.join(" "), At::Style => dropdown_style.as_str()], + keyboard_ev(Ev::KeyUp, |ev| { + ev.stop_propagation(); + Msg::NoOp + }), div![ attrs![At::Class => format!("valueContainer {}", variant)], visibility_handler, @@ -333,7 +318,8 @@ where chevron_down, ], div![ - attrs![At::Class => "dropDown", At::Style => dropdown_style], + class!["dropDown"], + attrs![At::Style => dropdown_style.as_str()], text_input, clear_icon, option_list @@ -346,13 +332,12 @@ fn render_value(mut content: Node) -> Node { content } -fn into_multi_value(opt: Opt, field_id: FieldId) -> Node -where - Opt: SelectOption, -{ +fn into_multi_value(opt: StyledSelectChildBuilder, field_id: FieldId) -> Node { let close_icon = StyledIcon::build(Icon::Close).size(14).build().into_node(); - let value = opt.to_value(); - let mut opt = opt.into_value(); + let child = opt.build(DisplayType::SelectValue); + let value = child.value(); + + let mut opt = child.into_node(); opt.add_class("value"); opt.add_child(close_icon); diff --git a/jirs-client/src/shared/styled_select_child.rs b/jirs-client/src/shared/styled_select_child.rs new file mode 100644 index 00000000..e8d603d7 --- /dev/null +++ b/jirs-client/src/shared/styled_select_child.rs @@ -0,0 +1,240 @@ +use seed::{prelude::*, *}; + +use crate::shared::styled_select::Variant; +use crate::shared::ToNode; +use crate::Msg; + +pub trait ToStyledSelectChild { + fn to_select_child(&self) -> StyledSelectChildBuilder; +} + +pub enum DisplayType { + SelectOption, + SelectValue, +} + +pub struct StyledSelectChild { + name: Option, + icon: Option>, + text: Option, + display_type: DisplayType, + value: u32, + class_list: Vec, + variant: Variant, +} + +impl StyledSelectChild { + pub fn build() -> StyledSelectChildBuilder { + StyledSelectChildBuilder { + icon: None, + text: None, + name: None, + value: 0, + class_list: vec![], + variant: Default::default(), + } + } + + #[inline] + pub fn value(&self) -> u32 { + self.value + } +} + +impl ToNode for StyledSelectChild { + fn into_node(self) -> Node { + render(self) + } +} + +#[derive(Debug)] +pub struct StyledSelectChildBuilder { + icon: Option>, + text: Option, + name: Option, + value: u32, + class_list: Vec, + variant: Variant, +} + +impl PartialEq for StyledSelectChildBuilder { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl StyledSelectChildBuilder { + pub fn icon(mut self, icon: Node) -> Self { + self.icon = Some(icon); + self + } + + pub fn text(mut self, text: S) -> Self + where + S: Into, + { + self.text = Some(text.into()); + self + } + + pub fn name(mut self, name: S) -> Self + where + S: Into, + { + self.name = Some(name.into()); + self + } + + pub fn value(mut self, value: u32) -> Self { + self.value = value; + self + } + + pub fn match_text(&self, text: &str) -> bool { + self.text + .as_ref() + .map(|t| t.contains(text)) + .unwrap_or_default() + } + + pub fn add_class(mut self, name: S) -> Self + where + S: Into, + { + self.class_list.push(name.into()); + self + } + + pub fn build(self, display_type: DisplayType) -> StyledSelectChild { + StyledSelectChild { + name: self.name, + icon: self.icon, + text: self.text, + display_type, + value: self.value, + class_list: self.class_list, + variant: self.variant, + } + } +} + +pub fn render(values: StyledSelectChild) -> Node { + let StyledSelectChild { + name, + icon, + text, + display_type, + value: _, + mut class_list, + variant, + } = values; + + class_list.push(format!("{}", variant)); + + let label_class = match display_type { + DisplayType::SelectOption => vec![ + "optionLabel".to_string(), + variant.to_string(), + name.as_ref().cloned().unwrap_or_default(), + name.as_ref() + .map(|s| format!("{}Label", s)) + .unwrap_or_default(), + class_list.join(" "), + ], + DisplayType::SelectValue => vec![ + "selectItemLabel".to_string(), + variant.to_string(), + name.as_ref().cloned().unwrap_or_default(), + name.as_ref() + .map(|s| format!("{}Label", s)) + .unwrap_or_default(), + class_list.join(" "), + ], + } + .join(" "); + + let wrapper_class = match display_type { + DisplayType::SelectOption => vec![ + "optionItem".to_string(), + name.as_ref().cloned().unwrap_or_default(), + class_list.join(" "), + ], + DisplayType::SelectValue => vec![ + "selectItem".to_string(), + name.as_ref().cloned().unwrap_or_default(), + class_list.join(" "), + ], + } + .join(" "); + + let icon_node = match icon { + Some(icon) => icon, + _ => empty![], + }; + + let label_node = match text { + Some(text) => div![class![label_class.as_str()], text], + _ => empty![], + }; + + div![class![wrapper_class.as_str()], icon_node, label_node] +} + +impl ToStyledSelectChild for jirs_data::User { + fn to_select_child(&self) -> StyledSelectChildBuilder { + let avatar = crate::shared::styled_avatar::StyledAvatar::build() + .avatar_url(self.avatar_url.as_ref().cloned().unwrap_or_default()) + .size(20) + .name(self.name.as_str()) + .build() + .into_node(); + StyledSelectChild::build() + .value(self.id as u32) + .icon(avatar) + .text(self.name.as_str()) + } +} + +impl ToStyledSelectChild for jirs_data::IssuePriority { + fn to_select_child(&self) -> StyledSelectChildBuilder { + let icon = crate::shared::styled_icon::StyledIcon::build(self.clone().into()) + .add_class(self.to_string()) + .build() + .into_node(); + let text = self.to_string(); + + StyledSelectChild::build() + .icon(icon) + .value(self.clone().into()) + .text(text) + .add_class(format!("{}", self)) + } +} + +impl ToStyledSelectChild for jirs_data::IssueStatus { + fn to_select_child(&self) -> StyledSelectChildBuilder { + let text = self.to_label(); + + StyledSelectChild::build() + .value(self.clone().into()) + .add_class(text.clone()) + .text(text) + } +} + +impl ToStyledSelectChild for jirs_data::IssueType { + fn to_select_child(&self) -> StyledSelectChildBuilder { + let name = self.to_label().to_owned(); + + let type_icon = crate::shared::styled_icon::StyledIcon::build(self.clone().into()) + .add_class(name.as_str()) + .build() + .into_node(); + + StyledSelectChild::build() + .add_class(name.as_str()) + .text(name) + .icon(type_icon) + .value(self.clone().into()) + } +} diff --git a/jirs-client/src/shared/styled_textarea.rs b/jirs-client/src/shared/styled_textarea.rs index 6cd8a03a..20f93d78 100644 --- a/jirs-client/src/shared/styled_textarea.rs +++ b/jirs-client/src/shared/styled_textarea.rs @@ -140,6 +140,10 @@ pub fn render(values: StyledTextarea) -> Node { Msg::InputChanged(id, value) }); handlers.push(text_input_handler); + handlers.push(keyboard_ev(Ev::KeyUp, |ev| { + ev.stop_propagation(); + Msg::NoOp + })); class_list.push("textAreaInput".to_string()); diff --git a/jirs-client/src/ws/issue.rs b/jirs-client/src/ws/issue.rs index ecc2b0a2..e2d7b47f 100644 --- a/jirs-client/src/ws/issue.rs +++ b/jirs-client/src/ws/issue.rs @@ -2,7 +2,6 @@ use jirs_data::*; use crate::api::send_ws_msg; use crate::model::Model; -use crate::IssueId; pub fn drag_started(issue_id: IssueId, model: &mut Model) { model.project_page.dragged_issue_id = Some(issue_id); diff --git a/jirs-data/src/lib.rs b/jirs-data/src/lib.rs index 28dab0a3..1e7832e5 100644 --- a/jirs-data/src/lib.rs +++ b/jirs-data/src/lib.rs @@ -163,19 +163,6 @@ impl IssueStatus { IssueStatus::Done => "Done", } } - - pub fn to_payload(&self) -> &str { - match self { - IssueStatus::Backlog => "backlog", - IssueStatus::Selected => "selected", - IssueStatus::InProgress => "in_progress", - IssueStatus::Done => "done", - } - } - - pub fn match_name(&self, name: &str) -> bool { - self.to_payload() == name - } } #[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))] diff --git a/react-client/src/Project/Board/IssueDetails/Comments/Create/index.jsx b/react-client/src/Project/Board/IssueDetails/Comments/Create/index.jsx index 51b66015..40f748ef 100644 --- a/react-client/src/Project/Board/IssueDetails/Comments/Create/index.jsx +++ b/react-client/src/Project/Board/IssueDetails/Comments/Create/index.jsx @@ -1,13 +1,13 @@ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; -import api from '../../../../../shared/utils/api'; -import toast from '../../../../../shared/utils/toast'; +import api from '../../../../../shared/utils/api'; +import toast from '../../../../../shared/utils/toast'; import { fetchCurrentUser } from "../../../../../actions/users"; -import BodyForm from '../BodyForm'; -import ProTip from './ProTip'; +import BodyForm from '../BodyForm'; +import ProTip from './ProTip'; import { Create, FakeTextarea, Right, UserAvatar } from './Styles'; class ProjectBoardIssueDetailsCommentsCreate extends React.Component { @@ -26,8 +26,7 @@ class ProjectBoardIssueDetailsCommentsCreate extends React.Component { handleCommentCreate = async () => { try { this.setCreatingTrue(); - const response = await api.post(`/comments`, { body: this.state.body, issueId: this.props.issueId }); - console.log(response); + await api.post(`/comments`, { body: this.state.body, issueId: this.props.issueId }); await this.props.fetchIssue(); this.setState({ isCreating: false, isFormOpen: false, body: '' }); } catch (error) {