Improve edit

This commit is contained in:
Adrian Woźniak 2020-04-09 16:03:11 +02:00
parent c3c8d1b66e
commit a97a96e9ac
14 changed files with 387 additions and 60 deletions

View File

@ -8,6 +8,32 @@
padding-right: 50px; 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 { .issueDetails > .content > .right {
width: 35%; width: 35%;
padding-top: 5px; padding-top: 5px;

View File

@ -63,31 +63,3 @@
.styledField > * { .styledField > * {
display: block; 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);
}

View File

@ -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);
}

View File

@ -12,6 +12,7 @@
@import "./css/styledButton.css"; @import "./css/styledButton.css";
@import "./css/styledInput.css"; @import "./css/styledInput.css";
@import "./css/styledModal.css"; @import "./css/styledModal.css";
@import "./css/styledTextArea.css";
@import "./css/styledForm.css"; @import "./css/styledForm.css";
@import "./css/app.css"; @import "./css/app.css";
@import "./css/issue.css"; @import "./css/issue.css";

View File

@ -28,6 +28,15 @@ pub type AvatarFilterActive = bool;
pub enum FieldId { pub enum FieldId {
// edit issue // edit issue
IssueTypeEditModalTop, IssueTypeEditModalTop,
TitleIssueEditModal,
DescriptionIssueEditModal,
StatusIssueEditModal,
AssigneesIssueEditModal,
ReporterIssueEditModal,
PriorityIssueEditModal,
EstimateIssueEditModal,
TimeSpendIssueEditModal,
TimeRemainingIssueEditModal,
// project boards // project boards
TextFilterBoard, TextFilterBoard,
// //
@ -53,6 +62,15 @@ impl std::fmt::Display for FieldId {
FieldId::ReporterAddIssueModal => f.write_str("reporterAddIssueModal"), FieldId::ReporterAddIssueModal => f.write_str("reporterAddIssueModal"),
FieldId::AssigneesAddIssueModal => f.write_str("assigneesAddIssueModal"), FieldId::AssigneesAddIssueModal => f.write_str("assigneesAddIssueModal"),
FieldId::IssuePriorityAddIssueModal => f.write_str("issuePriorityAddIssueModal"), 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"),
} }
} }
} }

View File

@ -1,29 +1,45 @@
use seed::{prelude::*, *}; 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::model::{EditIssueModal, ModalType, Model};
use crate::shared::styled_avatar::StyledAvatar;
use crate::shared::styled_button::StyledButton; use crate::shared::styled_button::StyledButton;
use crate::shared::styled_field::StyledField;
use crate::shared::styled_icon::{Icon, StyledIcon}; 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::shared::ToNode;
use crate::{FieldChange, FieldId, IssueId, Msg}; use crate::{FieldChange, FieldId, IssueId, Msg};
pub fn update(msg: &Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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<Msg> { pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg> {
let issue_id = issue.id; let issue_id = issue.id;
let issue_type_select = StyledSelect::build(FieldId::IssueTypeEditModalTop) let issue_type_select = StyledSelect::build(FieldId::IssueTypeEditModalTop)
.dropdown_width(150) .dropdown_width(150)
.name("type") .name("type")
.text_filter(modal.top_select_filter.as_str()) .text_filter(modal.top_type_state.text_filter.as_str())
.opened(modal.top_select_opened) .opened(modal.top_type_state.opened)
.valid(true) .valid(true)
.options(vec![ .options(
IssueTypeOption(issue_id, IssueType::Story), IssueType::ordered()
IssueTypeOption(issue_id, IssueType::Task), .into_iter()
IssueTypeOption(issue_id, IssueType::Bug), .map(|t| IssueTypeTopOption(issue_id, t))
]) .collect(),
.selected(vec![IssueTypeOption(issue_id, modal.value.clone())]) )
.selected(vec![IssueTypeTopOption(issue_id, modal.value.clone())])
.build() .build()
.into_node(); .into_node();
@ -75,6 +91,36 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
.build() .build()
.into_node(); .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![ div![
attrs![At::Class => "issueDetails"], attrs![At::Class => "issueDetails"],
div![ div![
@ -91,19 +137,19 @@ pub fn view(_model: &Model, issue: &Issue, modal: &EditIssueModal) -> Node<Msg>
attrs![At::Class => "content"], attrs![At::Class => "content"],
div![ div![
attrs![At::Class => "left"], attrs![At::Class => "left"],
div![attrs![At::Class => "title"]], title,
div![attrs![At::Class => "description"]], div![attrs![At::Class => "description"]],
div![attrs![At::Class => "comments"]], div![attrs![At::Class => "comments"]],
], ],
div![attrs![At::Class => "right"]], div![attrs![At::Class => "right"], status],
], ],
] ]
} }
#[derive(PartialOrd, PartialEq, Debug)] #[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<Msg> { fn into_option(self) -> Node<Msg> {
let name = self.1.to_label().to_owned(); let name = self.1.to_label().to_owned();
@ -113,9 +159,9 @@ impl crate::shared::styled_select::SelectOption for IssueTypeOption {
.into_node(); .into_node();
div![ div![
attrs![At::Class => "type"], attrs![At::Class => "optionItem"],
icon, 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() self.1.clone().into()
} }
} }
/////
#[derive(PartialOrd, PartialEq, Debug)]
pub struct IssueStatusOption(pub IssueId, pub IssueStatus);
impl SelectOption for IssueStatusOption {
fn into_option(self) -> Node<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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<Msg> {
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
}
}

View File

@ -5,7 +5,7 @@ use jirs_data::{Issue, IssueType, UpdateIssuePayload};
use crate::api::send_ws_msg; use crate::api::send_ws_msg;
use crate::model::{AddIssueModal, EditIssueModal, ModalType, Page}; use crate::model::{AddIssueModal, EditIssueModal, ModalType, Page};
use crate::shared::styled_modal::{StyledModal, Variant as ModalVariant}; 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::shared::{find_issue, ToNode};
use crate::{model, FieldChange, FieldId, Msg}; use crate::{model, FieldChange, FieldId, Msg};
@ -43,10 +43,13 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
*issue_id, *issue_id,
EditIssueModal { EditIssueModal {
id: *issue_id, id: *issue_id,
top_select_opened: false,
top_select_filter: "".to_string(),
value, value,
link_copied: false, 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>
Msg::StyledSelectChanged(FieldId::IssueTypeEditModalTop, change) => { Msg::StyledSelectChanged(FieldId::IssueTypeEditModalTop, change) => {
match (change, model.modals.last_mut()) { match (change, model.modals.last_mut()) {
(StyledSelectChange::Text(ref text), Some(ModalType::EditIssue(_, modal))) => { (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), StyledSelectChange::DropDownVisibility(flag),
Some(ModalType::EditIssue(_, modal)), Some(ModalType::EditIssue(_, modal)),
) => { ) => {
modal.top_select_opened = *flag; modal.top_type_state.opened = *flag;
} }
( (
StyledSelectChange::Changed(value), StyledSelectChange::Changed(value),
@ -107,6 +110,7 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
_ => (), _ => (),
} }
add_issue::update(msg, model, orders); add_issue::update(msg, model, orders);
issue_details::update(msg, model, orders);
} }
pub fn view(model: &model::Model) -> Node<Msg> { pub fn view(model: &model::Model) -> Node<Msg> {

View File

@ -20,10 +20,13 @@ pub enum ModalType {
#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]
pub struct EditIssueModal { pub struct EditIssueModal {
pub id: i32, pub id: i32,
pub top_select_opened: bool,
pub top_select_filter: String,
pub value: IssueType, pub value: IssueType,
pub link_copied: bool, 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)] #[derive(Clone, Debug, PartialOrd, PartialEq, Hash)]

View File

@ -59,7 +59,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
}) })
.last(); .last();
if let Some(m) = modal { if let Some(m) = modal {
m.top_select_filter = text; m.top_type_state.text_filter = text;
} }
} }
Msg::InputChanged(FieldId::TextFilterBoard, text) => { Msg::InputChanged(FieldId::TextFilterBoard, text) => {

View File

@ -66,6 +66,7 @@ pub fn render(values: StyledField) -> Node<Msg> {
Some(s) => div![attrs![At::Class => "styledTip"], s], Some(s) => div![attrs![At::Class => "styledTip"], s],
_ => empty![], _ => empty![],
}; };
div![ div![
attrs![At::Class => "styledField"], attrs![At::Class => "styledField"],
seed::label![attrs![At::Class => "styledLabel"], label], seed::label![attrs![At::Class => "styledLabel"], label],

View File

@ -7,6 +7,9 @@ use crate::{FieldId, Msg};
pub struct StyledTextarea { pub struct StyledTextarea {
id: FieldId, id: FieldId,
height: usize, height: usize,
max_height: usize,
value: String,
class_list: Vec<String>,
} }
impl ToNode for StyledTextarea { impl ToNode for StyledTextarea {
@ -24,19 +27,51 @@ impl StyledTextarea {
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct StyledTextareaBuilder { pub struct StyledTextareaBuilder {
height: Option<usize>, height: Option<usize>,
max_height: Option<usize>,
on_change: Option<EventHandler<Msg>>, on_change: Option<EventHandler<Msg>>,
value: String,
class_list: Vec<String>,
} }
impl StyledTextareaBuilder { impl StyledTextareaBuilder {
#[inline]
pub fn height(mut self, height: usize) -> Self { pub fn height(mut self, height: usize) -> Self {
self.height = Some(height); self.height = Some(height);
self self
} }
#[inline]
pub fn max_height(mut self, height: usize) -> Self {
self.max_height = Some(height);
self
}
#[inline]
pub fn value<S>(mut self, value: S) -> Self
where
S: Into<String>,
{
self.value = value.into();
self
}
#[inline]
pub fn add_class<S>(mut self, value: S) -> Self
where
S: Into<String>,
{
self.class_list.push(value.into());
self
}
#[inline]
pub fn build(self, id: FieldId) -> StyledTextarea { pub fn build(self, id: FieldId) -> StyledTextarea {
StyledTextarea { StyledTextarea {
id, id,
value: self.value,
height: self.height.unwrap_or(110), 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 // * 17 is padding top + bottom
// * 2 is border top + bottom // * 2 is border top + bottom
pub fn render(values: StyledTextarea) -> Node<Msg> { pub fn render(values: StyledTextarea) -> Node<Msg> {
let StyledTextarea { id, height } = values; let StyledTextarea {
id,
height,
max_height,
value,
mut class_list,
} = values;
let mut style_list = vec![]; 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![]; let mut handlers = vec![];
@ -88,15 +134,18 @@ pub fn render(values: StyledTextarea) -> Node<Msg> {
let text_input_handler = input_ev(Ev::KeyUp, move |value| Msg::InputChanged(id, value)); let text_input_handler = input_ev(Ev::KeyUp, move |value| Msg::InputChanged(id, value));
handlers.push(text_input_handler); handlers.push(text_input_handler);
class_list.push("textAreaInput".to_string());
div![ div![
attrs![At::Class => "styledTextArea"], attrs![At::Class => "styledTextArea"],
div![attrs![At::Class => "textAreaHeading"]], div![attrs![At::Class => "textAreaHeading"]],
textarea![ textarea![
attrs![ attrs![
At::Class => "textAreaInput"; At::Class => class_list.join(" ");
At::ContentEditable => "true"; At::ContentEditable => "true";
At::Style => style_list.join(";"); At::Style => style_list.join(";");
], ],
value,
handlers, handlers,
] ]
] ]

View File

@ -1,5 +1,3 @@
use seed::*;
use jirs_data::*; use jirs_data::*;
use crate::api::send_ws_msg; use crate::api::send_ws_msg;

View File

@ -98,6 +98,17 @@ impl Default for IssueStatus {
} }
} }
impl Into<u32> for IssueStatus {
fn into(self) -> u32 {
match self {
IssueStatus::Backlog => 0,
IssueStatus::Selected => 1,
IssueStatus::InProgress => 2,
IssueStatus::Done => 3,
}
}
}
impl FromStr for IssueStatus { impl FromStr for IssueStatus {
type Err = String; type Err = String;

View File

@ -1,7 +1,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import { color, font } from '../../../../shared/utils/styles'; import { color, font } from '../../../../shared/utils/styles';
import { Textarea } from '../../../../shared/components'; import { Textarea } from '../../../../shared/components';
export const TitleTextarea = styled(Textarea)` export const TitleTextarea = styled(Textarea)`
margin: 18px 0 0 -8px; margin: 18px 0 0 -8px;
@ -17,9 +17,10 @@ export const TitleTextarea = styled(Textarea)`
box-shadow: 0 0 0 1px transparent; box-shadow: 0 0 0 1px transparent;
transition: background 0.1s; transition: background 0.1s;
font-size: 24px font-size: 24px
${ font.medium };font-weight: normal; ${font.medium};
font-weight: normal;
&:hover:not(:focus) { &:hover:not(:focus) {
background: ${ color.backgroundLight }; background: ${color.backgroundLight};
} }
} }
`; `;