diff --git a/jirs-client/js/css/issue.css b/jirs-client/js/css/issue.css index 21d9c0a4..49afa05f 100644 --- a/jirs-client/js/css/issue.css +++ b/jirs-client/js/css/issue.css @@ -8,6 +8,32 @@ padding-right: 50px; } +.issueDetails > .content > .left > .styledTextArea { + margin: 18px 0 0 -8px; + height: 44px; + width: 100%; +} + +.issueDetails > .content > .left > .styledTextArea > textarea { + padding: 7px 7px 8px; + line-height: 1.28; + resize: none; + transition: background 0.1s; + font-size: 24px; + font-family: var(--font-medium); + font-weight: normal; +} + +.issueDetails > .content > .left > .styledTextArea > textarea:not(:focus) { + background: #fff; + border: 1px solid transparent; + box-shadow: 0 0 0 1px transparent; +} + +.issueDetails > .content > .left > .styledTextArea > textarea:hover:not(:focus) { + background: var(--backgroundLight); +} + .issueDetails > .content > .right { width: 35%; padding-top: 5px; diff --git a/jirs-client/js/css/styledForm.css b/jirs-client/js/css/styledForm.css index 0ca89d1e..9ad27183 100644 --- a/jirs-client/js/css/styledForm.css +++ b/jirs-client/js/css/styledForm.css @@ -63,31 +63,3 @@ .styledField > * { display: block; } - -.styledTextArea { - display: inline-block; - width: 100%; -} - -.styledTextArea > textarea { - overflow-y: hidden; - width: 100%; - padding: 8px 12px 9px; - border-radius: 3px; - border: 1px solid var(--borderLightest); - color: var(--textDarkest); - background: var(--backgroundLightest); - font-family: var(--font-regular); - font-weight: normal; - font-size: 15px -} - -.styledTextArea > textarea:focus { - background: #fff; - border: 1px solid var(--borderInputFocus); - box-shadow: 0 0 0 1px var(--borderInputFocus); -} - -.styledTextArea > textarea.invalid:focus { - border: 1px solid var(--danger); -} diff --git a/jirs-client/js/css/styledTextArea.css b/jirs-client/js/css/styledTextArea.css new file mode 100644 index 00000000..21c3d3a2 --- /dev/null +++ b/jirs-client/js/css/styledTextArea.css @@ -0,0 +1,27 @@ +.styledTextArea { + display: inline-block; + width: 100%; +} + +.styledTextArea > textarea { + overflow-y: hidden; + width: 100%; + padding: 8px 12px 9px; + border-radius: 3px; + border: 1px solid var(--borderLightest); + color: var(--textDarkest); + background: var(--backgroundLightest); + font-family: var(--font-regular); + font-weight: normal; + font-size: 15px +} + +.styledTextArea > textarea:focus { + background: #fff; + border: 1px solid var(--borderInputFocus); + box-shadow: 0 0 0 1px var(--borderInputFocus); +} + +.styledTextArea > textarea.invalid:focus { + border: 1px solid var(--danger); +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index 2004ad1a..a51f01ec 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -12,6 +12,7 @@ @import "./css/styledButton.css"; @import "./css/styledInput.css"; @import "./css/styledModal.css"; +@import "./css/styledTextArea.css"; @import "./css/styledForm.css"; @import "./css/app.css"; @import "./css/issue.css"; diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index 01250ebd..088a30b8 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -28,6 +28,15 @@ pub type AvatarFilterActive = bool; pub enum FieldId { // edit issue IssueTypeEditModalTop, + TitleIssueEditModal, + DescriptionIssueEditModal, + StatusIssueEditModal, + AssigneesIssueEditModal, + ReporterIssueEditModal, + PriorityIssueEditModal, + EstimateIssueEditModal, + TimeSpendIssueEditModal, + TimeRemainingIssueEditModal, // project boards TextFilterBoard, // @@ -53,6 +62,15 @@ impl std::fmt::Display for FieldId { 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"), } } } diff --git a/jirs-client/src/modal/issue_details.rs b/jirs-client/src/modal/issue_details.rs index d33b2951..41a80eb6 100644 --- a/jirs-client/src/modal/issue_details.rs +++ b/jirs-client/src/modal/issue_details.rs @@ -1,29 +1,45 @@ use seed::{prelude::*, *}; -use jirs_data::{Issue, IssueType}; +use jirs_data::{Issue, IssuePriority, IssueStatus, IssueType, ToVec, User}; use crate::model::{EditIssueModal, ModalType, Model}; +use crate::shared::styled_avatar::StyledAvatar; use crate::shared::styled_button::StyledButton; +use crate::shared::styled_field::StyledField; use crate::shared::styled_icon::{Icon, StyledIcon}; -use crate::shared::styled_select::StyledSelect; +use crate::shared::styled_select::{SelectOption, StyledSelect}; +use crate::shared::styled_textarea::StyledTextarea; use crate::shared::ToNode; use crate::{FieldChange, FieldId, IssueId, Msg}; +pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders) { + let modal: &mut EditIssueModal = match model.modals.get_mut(0) { + Some(ModalType::EditIssue(_issue_id, modal)) => modal, + _ => return, + }; + modal.top_type_state.update(msg, orders); + modal.status_state.update(msg, orders); + modal.reporter_state.update(msg, orders); + modal.assignees_state.update(msg, orders); + modal.priority_state.update(msg, orders); +} + pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node { let issue_id = issue.id; let issue_type_select = StyledSelect::build(FieldId::IssueTypeEditModalTop) .dropdown_width(150) .name("type") - .text_filter(modal.top_select_filter.as_str()) - .opened(modal.top_select_opened) + .text_filter(modal.top_type_state.text_filter.as_str()) + .opened(modal.top_type_state.opened) .valid(true) - .options(vec![ - IssueTypeOption(issue_id, IssueType::Story), - IssueTypeOption(issue_id, IssueType::Task), - IssueTypeOption(issue_id, IssueType::Bug), - ]) - .selected(vec![IssueTypeOption(issue_id, modal.value.clone())]) + .options( + IssueType::ordered() + .into_iter() + .map(|t| IssueTypeTopOption(issue_id, t)) + .collect(), + ) + .selected(vec![IssueTypeTopOption(issue_id, modal.value.clone())]) .build() .into_node(); @@ -75,6 +91,36 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node .build() .into_node(); + // left + let title = StyledTextarea::build() + .value(issue.title.as_str()) + .add_class("textarea") + .max_height(48) + .height(0) + .build(FieldId::TitleIssueEditModal) + .into_node(); + + // right + let status = StyledSelect::build(FieldId::StatusIssueEditModal) + .name("status") + .opened(modal.status_state.opened) + .text_filter(modal.status_state.text_filter.as_str()) + .options( + IssueStatus::ordered() + .into_iter() + .map(|opt| IssueStatusOption(issue_id, opt)) + .collect(), + ) + .selected(vec![IssueStatusOption(issue_id, issue.status.clone())]) + .valid(true) + .build() + .into_node(); + // let status_field = StyledField::build() + // .input(status) + // .label("Status") + // .build() + // .into_node(); + div![ attrs![At::Class => "issueDetails"], div![ @@ -91,19 +137,19 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node attrs![At::Class => "content"], div![ attrs![At::Class => "left"], - div![attrs![At::Class => "title"]], + title, div![attrs![At::Class => "description"]], div![attrs![At::Class => "comments"]], ], - div![attrs![At::Class => "right"]], + div![attrs![At::Class => "right"], status], ], ] } #[derive(PartialOrd, PartialEq, Debug)] -pub struct IssueTypeOption(pub IssueId, pub IssueType); +pub struct IssueTypeTopOption(pub IssueId, pub IssueType); -impl crate::shared::styled_select::SelectOption for IssueTypeOption { +impl SelectOption for IssueTypeTopOption { fn into_option(self) -> Node { let name = self.1.to_label().to_owned(); @@ -113,9 +159,9 @@ impl crate::shared::styled_select::SelectOption for IssueTypeOption { .into_node(); div![ - attrs![At::Class => "type"], + attrs![At::Class => "optionItem"], icon, - div![attrs![At::Class => "typeLabel"], name] + div![attrs![At::Class => "optionLabel typeLabel"], name] ] } @@ -142,3 +188,173 @@ impl crate::shared::styled_select::SelectOption for IssueTypeOption { 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 633926f8..35a55809 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -5,7 +5,7 @@ use jirs_data::{Issue, IssueType, UpdateIssuePayload}; use crate::api::send_ws_msg; use crate::model::{AddIssueModal, EditIssueModal, ModalType, Page}; use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant}; -use crate::shared::styled_select::StyledSelectChange; +use crate::shared::styled_select::{StyledSelectChange, StyledSelectState}; use crate::shared::{find_issue, ToNode}; use crate::{model, FieldChange, FieldId, Msg}; @@ -43,10 +43,13 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders *issue_id, EditIssueModal { id: *issue_id, - top_select_opened: false, - top_select_filter: "".to_string(), value, link_copied: false, + 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), }, )); } @@ -59,13 +62,13 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders Msg::StyledSelectChanged(FieldId::IssueTypeEditModalTop, change) => { match (change, model.modals.last_mut()) { (StyledSelectChange::Text(ref text), Some(ModalType::EditIssue(_, modal))) => { - modal.top_select_filter = text.clone(); + modal.top_type_state.text_filter = text.clone(); } ( StyledSelectChange::DropDownVisibility(flag), Some(ModalType::EditIssue(_, modal)), ) => { - modal.top_select_opened = *flag; + modal.top_type_state.opened = *flag; } ( StyledSelectChange::Changed(value), @@ -107,6 +110,7 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders _ => (), } add_issue::update(msg, model, orders); + issue_details::update(msg, model, orders); } pub fn view(model: &model::Model) -> Node { diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index c892c183..a6087047 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -20,10 +20,13 @@ pub enum ModalType { #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] pub struct EditIssueModal { pub id: i32, - pub top_select_opened: bool, - pub top_select_filter: String, pub value: IssueType, pub link_copied: bool, + pub top_type_state: StyledSelectState, + pub status_state: StyledSelectState, + pub reporter_state: StyledSelectState, + pub assignees_state: StyledSelectState, + pub priority_state: StyledSelectState, } #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] diff --git a/jirs-client/src/project.rs b/jirs-client/src/project.rs index 9f4795d6..7c7841f0 100644 --- a/jirs-client/src/project.rs +++ b/jirs-client/src/project.rs @@ -59,7 +59,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order }) .last(); if let Some(m) = modal { - m.top_select_filter = text; + m.top_type_state.text_filter = text; } } Msg::InputChanged(FieldId::TextFilterBoard, text) => { diff --git a/jirs-client/src/shared/styled_field.rs b/jirs-client/src/shared/styled_field.rs index 0da11470..32aec1e9 100644 --- a/jirs-client/src/shared/styled_field.rs +++ b/jirs-client/src/shared/styled_field.rs @@ -66,6 +66,7 @@ pub fn render(values: StyledField) -> Node { Some(s) => div![attrs![At::Class => "styledTip"], s], _ => empty![], }; + div![ attrs![At::Class => "styledField"], seed::label![attrs![At::Class => "styledLabel"], label], diff --git a/jirs-client/src/shared/styled_textarea.rs b/jirs-client/src/shared/styled_textarea.rs index 85534468..50366c5b 100644 --- a/jirs-client/src/shared/styled_textarea.rs +++ b/jirs-client/src/shared/styled_textarea.rs @@ -7,6 +7,9 @@ use crate::{FieldId, Msg}; pub struct StyledTextarea { id: FieldId, height: usize, + max_height: usize, + value: String, + class_list: Vec, } impl ToNode for StyledTextarea { @@ -24,19 +27,51 @@ impl StyledTextarea { #[derive(Debug, Default)] pub struct StyledTextareaBuilder { height: Option, + max_height: Option, on_change: Option>, + value: String, + class_list: Vec, } impl StyledTextareaBuilder { + #[inline] pub fn height(mut self, height: usize) -> Self { self.height = Some(height); self } + #[inline] + pub fn max_height(mut self, height: usize) -> Self { + self.max_height = Some(height); + self + } + + #[inline] + pub fn value(mut self, value: S) -> Self + where + S: Into, + { + self.value = value.into(); + self + } + + #[inline] + pub fn add_class(mut self, value: S) -> Self + where + S: Into, + { + self.class_list.push(value.into()); + self + } + + #[inline] pub fn build(self, id: FieldId) -> StyledTextarea { StyledTextarea { id, + value: self.value, height: self.height.unwrap_or(110), + class_list: self.class_list, + max_height: self.max_height.unwrap_or_default(), } } } @@ -55,9 +90,20 @@ const ADDITIONAL_HEIGHT: f64 = PADDING_TOP_BOTTOM + BORDER_TOP_BOTTOM; // * 17 is padding top + bottom // * 2 is border top + bottom pub fn render(values: StyledTextarea) -> Node { - let StyledTextarea { id, height } = values; + let StyledTextarea { + id, + height, + max_height, + value, + mut class_list, + } = values; let mut style_list = vec![]; - style_list.push(format!("min-height: {}px", height)); + if height > 0 { + style_list.push(format!("min-height: {}px", height)); + } + if max_height > 0 { + style_list.push(format!("max-height: {}px", max_height)); + } let mut handlers = vec![]; @@ -88,15 +134,18 @@ pub fn render(values: StyledTextarea) -> Node { let text_input_handler = input_ev(Ev::KeyUp, move |value| Msg::InputChanged(id, value)); handlers.push(text_input_handler); + class_list.push("textAreaInput".to_string()); + div![ attrs![At::Class => "styledTextArea"], div![attrs![At::Class => "textAreaHeading"]], textarea![ attrs![ - At::Class => "textAreaInput"; + At::Class => class_list.join(" "); At::ContentEditable => "true"; At::Style => style_list.join(";"); ], + value, handlers, ] ] diff --git a/jirs-client/src/ws/issue.rs b/jirs-client/src/ws/issue.rs index 81ac78e5..b247efdb 100644 --- a/jirs-client/src/ws/issue.rs +++ b/jirs-client/src/ws/issue.rs @@ -1,5 +1,3 @@ -use seed::*; - use jirs_data::*; use crate::api::send_ws_msg; diff --git a/jirs-data/src/lib.rs b/jirs-data/src/lib.rs index 0ff168ac..c01905be 100644 --- a/jirs-data/src/lib.rs +++ b/jirs-data/src/lib.rs @@ -98,6 +98,17 @@ impl Default for IssueStatus { } } +impl Into for IssueStatus { + fn into(self) -> u32 { + match self { + IssueStatus::Backlog => 0, + IssueStatus::Selected => 1, + IssueStatus::InProgress => 2, + IssueStatus::Done => 3, + } + } +} + impl FromStr for IssueStatus { type Err = String; diff --git a/react-client/src/Project/Board/IssueDetails/Title/Styles.js b/react-client/src/Project/Board/IssueDetails/Title/Styles.js index 0a21cf26..a28e8c41 100644 --- a/react-client/src/Project/Board/IssueDetails/Title/Styles.js +++ b/react-client/src/Project/Board/IssueDetails/Title/Styles.js @@ -1,7 +1,7 @@ import styled from 'styled-components'; import { color, font } from '../../../../shared/utils/styles'; -import { Textarea } from '../../../../shared/components'; +import { Textarea } from '../../../../shared/components'; export const TitleTextarea = styled(Textarea)` margin: 18px 0 0 -8px; @@ -17,9 +17,10 @@ export const TitleTextarea = styled(Textarea)` box-shadow: 0 0 0 1px transparent; transition: background 0.1s; font-size: 24px - ${ font.medium };font-weight: normal; + ${font.medium}; + font-weight: normal; &:hover:not(:focus) { - background: ${ color.backgroundLight }; + background: ${color.backgroundLight}; } } `;