From e807dae81eb3dbda60f5ba07b2d5ae049f99e7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Wed, 13 Oct 2021 16:01:44 +0200 Subject: [PATCH] Refactor --- shared/jirs-data/src/fields.rs | 16 +- web/js/css/login.scss | 24 ++ web/src/components/events.rs | 193 +++++++++++++++ web/src/components/mod.rs | 1 + web/src/components/styled_avatar.rs | 2 +- web/src/components/styled_checkbox.rs | 11 +- web/src/components/styled_confirm_modal.rs | 2 +- web/src/components/styled_date_time_input.rs | 73 ++---- web/src/components/styled_editor.rs | 3 + web/src/components/styled_image_input.rs | 17 +- web/src/components/styled_input.rs | 11 +- web/src/components/styled_link.rs | 24 +- web/src/components/styled_md_editor.rs | 17 +- web/src/components/styled_modal.rs | 16 +- web/src/components/styled_select.rs | 78 ++----- web/src/components/styled_select_child.rs | 51 ++-- web/src/components/styled_textarea.rs | 31 +-- web/src/fields.rs | 4 +- web/src/images/email_send.rs | 16 ++ web/src/images/mod.rs | 3 +- web/src/lib.rs | 1 + web/src/pages/epics_page/view.rs | 77 +++--- .../issues_and_filters/view/issue_info.rs | 6 +- web/src/pages/profile_page/events.rs | 12 + web/src/pages/profile_page/mod.rs | 1 + web/src/pages/profile_page/view.rs | 26 +-- web/src/pages/project_page/events.rs | 55 ++++- web/src/pages/project_page/view.rs | 2 +- web/src/pages/project_page/view/board.rs | 27 +-- web/src/pages/project_page/view/filters.rs | 21 +- web/src/pages/project_settings_page/view.rs | 15 +- web/src/pages/reports_page/view.rs | 8 +- web/src/pages/sign_in_page/events.rs | 37 +++ web/src/pages/sign_in_page/mod.rs | 1 + web/src/pages/sign_in_page/model.rs | 17 +- web/src/pages/sign_in_page/update.rs | 16 +- web/src/pages/sign_in_page/view.rs | 220 ++++++++++-------- web/src/pages/sign_up_page/update.rs | 7 + web/src/pages/sign_up_page/view.rs | 50 ++-- web/src/ws/mod.rs | 13 +- 40 files changed, 713 insertions(+), 492 deletions(-) create mode 100644 web/src/components/events.rs create mode 100644 web/src/images/email_send.rs create mode 100644 web/src/pages/profile_page/events.rs create mode 100644 web/src/pages/sign_in_page/events.rs diff --git a/shared/jirs-data/src/fields.rs b/shared/jirs-data/src/fields.rs index ed1efdf0..f60b8838 100644 --- a/shared/jirs-data/src/fields.rs +++ b/shared/jirs-data/src/fields.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum ProjectFieldId { Name, Url, @@ -11,20 +11,20 @@ pub enum ProjectFieldId { IssueStatusName, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum SignInFieldId { Username, Email, Token, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum SignUpFieldId { Username, Email, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum UsersFieldId { Username, Email, @@ -34,17 +34,17 @@ pub enum UsersFieldId { TextEditorMode, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum InviteFieldId { Token, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum CommentFieldId { Body, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum IssueFieldId { Type, Title, @@ -62,7 +62,7 @@ pub enum IssueFieldId { EpicEndsAt, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum EpicFieldId { Name, StartsAt, diff --git a/web/js/css/login.scss b/web/js/css/login.scss index 75d5b417..0ad22425 100644 --- a/web/js/css/login.scss +++ b/web/js/css/login.scss @@ -35,6 +35,18 @@ text-align: center; } + > .styledField { + .emailSend { + text-align: center; + + > div > svg { + width: 160px; + margin: 0 auto; + fill: var(--primary); + } + } + } + > .noPasswordSection { line-height: 32px; margin-top: 15px; @@ -58,6 +70,18 @@ justify-content: space-between; } } + + .error { + > p { + line-height: 1.4285; + color: var(--danger); + font-family: var(--font-medium); + text-align: center; + font-size: 14.5px; + border-top: 1px solid var(--danger); + margin-top: 15px; + } + } } @media (min-width: 1240px) { diff --git a/web/src/components/events.rs b/web/src/components/events.rs new file mode 100644 index 00000000..8ba8f5ff --- /dev/null +++ b/web/src/components/events.rs @@ -0,0 +1,193 @@ +use std::str::FromStr; + +use seed::prelude::{ev, Ev, EventHandler}; + +use crate::components::styled_date_time_input::StyledDateTimeChanged; +use crate::components::styled_md_editor::MdEditorMode; +use crate::components::styled_select::StyledSelectChanged; +use crate::{resolve_page, FieldChange}; + +type EvHandler = EventHandler; + +pub fn on_click_change_number_input(field_id: crate::FieldId, value: u32) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + + crate::Msg::U32InputChanged(field_id, value) + }) +} + +pub fn on_change_set_str_input(field_id: crate::FieldId) -> EvHandler { + ev(Ev::Change, move |ev| { + ev.stop_propagation(); + + let target = ev.target().unwrap(); + crate::Msg::StrInputChanged(field_id, seed::to_input(&target).value()) + }) +} + +pub fn on_click_drop_modal() -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + + crate::Msg::ModalDropped + }) +} + +pub fn on_click_change_day(field_id: crate::FieldId, date: chrono::NaiveDateTime) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + + // log::info!("{:?}", date); + crate::Msg::StyledDateTimeInputChanged( + field_id, + StyledDateTimeChanged::DayChanged(Some(date)), + ) + }) +} + +pub fn on_click_change_date_time_visibility(field_id: crate::FieldId, visible: bool) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.prevent_default(); + ev.stop_propagation(); + + crate::Msg::StyledDateTimeInputChanged( + field_id, + StyledDateTimeChanged::PopupVisibilityChanged(!visible), + ) + }) +} + +pub fn on_click_change_month(field_id: crate::FieldId, date: chrono::NaiveDateTime) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + + crate::Msg::StyledDateTimeInputChanged( + field_id, + StyledDateTimeChanged::MonthChanged(Some(date)), + ) + }) +} + +pub fn on_change_image_input(field_id: crate::FieldId) -> EvHandler { + ev(Ev::Change, move |ev| { + let target = ev.target().unwrap(); + let input = seed::to_input(&target); + let v = input + .files() + .map(|list| { + (0..list.length()) + .filter_map(|i| list.get(i)) + .collect::>() + }) + .unwrap_or_default(); + crate::Msg::FileInputChanged(field_id, v) + }) +} + +pub fn on_keyup_change_select_text(field_id: crate::FieldId) -> EvHandler { + ev(Ev::KeyUp, move |ev| { + ev.stop_propagation(); + + let target = ev.target().unwrap(); + let value = seed::to_input(&target).value(); + crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::Text(value)) + }) +} + +pub fn on_click_change_select_dropdown_visibility( + field_id: crate::FieldId, + opened: bool, +) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + + crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::DropDownVisibility(!opened)) + }) +} + +pub fn on_click_change_select_selected(field_id: crate::FieldId, value: Option) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::Changed(value)) + }) +} + +pub fn on_click_change_select_remove_multi(field_id: crate::FieldId, value: u32) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + crate::Msg::StyledSelectChanged(field_id, StyledSelectChanged::RemoveMulti(value)) + }) +} + +pub fn on_click_change_page(href: String) -> EvHandler { + ev(Ev::Click, move |ev| { + if href.starts_with('/') { + ev.prevent_default(); + ev.stop_propagation(); + + if let Ok(url) = seed::Url::from_str(href.as_str()) { + url.go_and_push(); + return resolve_page(url).map(crate::Msg::ChangePage); + } + } + + None as Option + }) +} + +pub fn on_click_change_tab(field_id: crate::FieldId, new_mode: MdEditorMode) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + + crate::Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode)) + }) +} + +pub fn on_click_noop() -> EvHandler { + noop(Ev::Click) +} + +pub fn on_keyup_noop() -> EvHandler { + noop(Ev::KeyUp) +} + +fn noop(event: Ev) -> EvHandler { + ev(event, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + None as Option + }) +} + +pub fn on_event_change_text_value( + field_id: crate::FieldId, + update_event: Ev, + handler_disable_auto_resize: bool, +) -> EvHandler { + ev(update_event, move |event| { + event.stop_propagation(); + + let value = event + .target() + .map(|target| seed::to_textarea(&target).value()) + .unwrap_or_default(); + + if handler_disable_auto_resize && value.contains('\n') { + event.prevent_default(); + } + + crate::Msg::StrInputChanged( + field_id, + if handler_disable_auto_resize { + value.trim().to_string() + } else { + value + }, + ) + }) +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 0c9183ac..a8127081 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -18,3 +18,4 @@ pub mod styled_select_child; pub mod styled_textarea; pub mod styled_tip; pub mod styled_tooltip; +mod events; diff --git a/web/src/components/styled_avatar.rs b/web/src/components/styled_avatar.rs index 6827d455..b950136a 100644 --- a/web/src/components/styled_avatar.rs +++ b/web/src/components/styled_avatar.rs @@ -32,7 +32,7 @@ impl<'l> StyledAvatar<'l> { .chars() .rev() .last() - .map(|c| c.to_string()) + .map(String::from) .unwrap_or_default(); match avatar_url { Some(url) => { diff --git a/web/src/components/styled_checkbox.rs b/web/src/components/styled_checkbox.rs index 70ac55c3..b9203f9b 100644 --- a/web/src/components/styled_checkbox.rs +++ b/web/src/components/styled_checkbox.rs @@ -1,6 +1,7 @@ use seed::prelude::*; use seed::*; +use super::events; use crate::{FieldId, Msg}; #[derive(Debug)] @@ -56,15 +57,7 @@ impl<'l> ChildBuilder<'l> { #[inline(always)] pub fn render(self) -> Node { let id = self.field_id.to_string(); - let handler: EventHandler = { - let id = self.field_id; - let value = self.value; - mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - Msg::U32InputChanged(id, value) - }) - }; + let handler = events::on_click_change_number_input(self.field_id, self.value); div![ C![ diff --git a/web/src/components/styled_confirm_modal.rs b/web/src/components/styled_confirm_modal.rs index 3b2bbae3..00e7c608 100644 --- a/web/src/components/styled_confirm_modal.rs +++ b/web/src/components/styled_confirm_modal.rs @@ -48,7 +48,7 @@ impl<'l> StyledConfirmModal<'l> { _ => cancel_text, }), variant: ButtonVariant::Secondary, - on_click: Some(mouse_ev(Ev::Click, |_| Msg::ModalDropped)), + on_click: Some(super::events::on_click_drop_modal()), ..Default::default() } .render(); diff --git a/web/src/components/styled_date_time_input.rs b/web/src/components/styled_date_time_input.rs index 4b03a54e..87ddd77f 100644 --- a/web/src/components/styled_date_time_input.rs +++ b/web/src/components/styled_date_time_input.rs @@ -105,13 +105,16 @@ impl StyledDateTimeInput { let calendar_start = StyledDateTimeInput::calendar_start(start); let calendar_end = StyledDateTimeInput::calendar_end(end); let current_month_range = start..=end; - // let current = calendar_start; - let mut weeks = vec![]; - let mut current_week = vec![]; + + let mut weeks = Vec::with_capacity(7); + let mut current_week = Vec::with_capacity(6); for current in DateRange(calendar_start, calendar_end) { if current.weekday() == Weekday::Mon && !current_week.is_empty() { - weeks.push(div![C!["week"], std::mem::take(&mut current_week)]); + weeks.push(div![ + C!["week"], + std::mem::replace(&mut current_week, Vec::with_capacity(7)) + ]); } current_week.push( @@ -130,21 +133,14 @@ impl StyledDateTimeInput { } let left_action = { - let field_id = self.field_id.clone(); - let current = timestamp; - let on_click_left = mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - let last_day_of_prev_month = current.with_day0(0).unwrap() - Duration::days(1); + let on_click_left = { + let last_day_of_prev_month = timestamp.with_day0(0).unwrap() - Duration::days(1); let date = last_day_of_prev_month .with_day0(timestamp.day0()) .unwrap_or(last_day_of_prev_month); - Msg::StyledDateTimeInputChanged( - field_id, - StyledDateTimeChanged::MonthChanged(Some(date)), - ) - }); + super::events::on_click_change_month(self.field_id.clone(), date) + }; StyledButton { on_click: Some(on_click_left), icon: Some(StyledIcon::from(Icon::DoubleLeft).render()), @@ -153,13 +149,11 @@ impl StyledDateTimeInput { } .render() }; + let right_action = { - let field_id = self.field_id.clone(); - let current = timestamp; - let on_click_right = mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - let first_day_of_next_month = (current + Duration::days(32)).with_day0(0).unwrap(); + let on_click_right = { + let first_day_of_next_month = + (timestamp + Duration::days(32)).with_day0(0).unwrap(); let last_day_of_next_month = (first_day_of_next_month + Duration::days(32)) .with_day0(0) .unwrap() @@ -167,11 +161,8 @@ impl StyledDateTimeInput { let date = first_day_of_next_month .with_day0(timestamp.day0()) .unwrap_or(last_day_of_next_month); - Msg::StyledDateTimeInputChanged( - field_id, - StyledDateTimeChanged::MonthChanged(Some(date)), - ) - }); + super::events::on_click_change_month(self.field_id.clone(), date) + }; StyledButton { on_click: Some(on_click_right), icon: Some(StyledIcon::from(Icon::DoubleRight).render()), @@ -210,16 +201,10 @@ impl StyledDateTimeInput { .render(); let input = { - let field_id = self.field_id.clone(); - let visible = self.popup_visible; - let on_focus = ev(Ev::Click, move |ev| { - ev.prevent_default(); - ev.stop_propagation(); - Msg::StyledDateTimeInputChanged( - field_id, - StyledDateTimeChanged::PopupVisibilityChanged(!visible), - ) - }); + let on_focus = super::events::on_click_change_date_time_visibility( + self.field_id.clone(), + self.popup_visible, + ); let text = self .timestamp .unwrap_or_else(|| Utc::now().naive_utc()) @@ -279,25 +264,13 @@ pub struct DayCell<'l> { impl<'l> DayCell<'l> { #[inline(always)] pub fn render(self) -> Node { - let on_click = { - let field_id = self.field_id.clone(); - let date = *self.current; - ev(Ev::Click, move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - log::info!("{:?}", date); - Msg::StyledDateTimeInputChanged( - field_id, - StyledDateTimeChanged::DayChanged(Some(date)), - ) - }) - }; + let on_click = super::events::on_click_change_day(self.field_id.clone(), *self.current); div![ C![ "day", format!("{}", self.current.weekday()), IF![self.is_selected() => "selected"], - if self.current_month_range.contains(&self.current) { + if self.current_month_range.contains(self.current) { "inCurrentMonth" } else { "outCurrentMonth" diff --git a/web/src/components/styled_editor.rs b/web/src/components/styled_editor.rs index 16a603ef..d29ccb28 100644 --- a/web/src/components/styled_editor.rs +++ b/web/src/components/styled_editor.rs @@ -68,6 +68,7 @@ impl StyledEditorState { } impl EditorMode { + #[inline(always)] pub fn update(&mut self, msg: &Msg, orders: &mut impl Orders) { match self { EditorMode::Md(state) => state.update(msg), @@ -167,10 +168,12 @@ fn build_state( } } +#[inline(always)] fn build_state_rte(field_id: FieldId) -> EditorMode { EditorMode::Rte(StyledRteState::new(field_id)) } +#[inline(always)] fn build_state_md(field_id: FieldId, text: &str, html: &str) -> EditorMode { EditorMode::Md(StyledMdEditorState::new( field_id, diff --git a/web/src/components/styled_image_input.rs b/web/src/components/styled_image_input.rs index 6f57c93d..a2a18b22 100644 --- a/web/src/components/styled_image_input.rs +++ b/web/src/components/styled_image_input.rs @@ -1,5 +1,5 @@ -use seed::prelude::*; use seed::*; +use seed::prelude::*; use web_sys::File; use crate::{FieldId, Msg}; @@ -45,20 +45,7 @@ impl<'l> StyledImageInput<'l> { url, } = self; - let field_id = id.clone(); - let on_change = ev(Ev::Change, move |ev| { - let target = ev.target().unwrap(); - let input = seed::to_input(&target); - let v = input - .files() - .map(|list| { - (0..list.length()) - .filter_map(|i| list.get(i)) - .collect::>() - }) - .unwrap_or_default(); - Msg::FileInputChanged(field_id, v) - }); + let on_change = super::events::on_change_image_input(id.clone()); let input_id = id.to_string(); div![ diff --git a/web/src/components/styled_input.rs b/web/src/components/styled_input.rs index 4fe5ae5b..953b849c 100644 --- a/web/src/components/styled_input.rs +++ b/web/src/components/styled_input.rs @@ -160,14 +160,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> { .map(|icon| StyledIcon::from(icon).render()) .unwrap_or(Node::Empty); - let on_change = { - let field_id = id.clone(); - ev(Ev::Change, move |event| { - event.stop_propagation(); - let target = event.target().unwrap(); - Msg::StrInputChanged(field_id, seed::to_input(&target).value()) - }) - }; + let on_change = super::events::on_change_set_str_input(id.clone()); div![ C![ @@ -183,7 +176,7 @@ impl<'l, 'm: 'l> StyledInput<'l, 'm> { "inputElement", variant.to_str(), input_class_list, - icon.as_ref().map(|_| "withIcon").unwrap_or_default() + icon.map(|_| "withIcon") ], attrs![ "id" => format!("{}", id), diff --git a/web/src/components/styled_link.rs b/web/src/components/styled_link.rs index b2bf8272..76bcbe64 100644 --- a/web/src/components/styled_link.rs +++ b/web/src/components/styled_link.rs @@ -1,9 +1,7 @@ -use std::str::FromStr; - -use seed::prelude::*; use seed::*; +use seed::prelude::*; -use crate::{resolve_page, Msg}; +use crate::Msg; #[derive(Debug, Default)] pub struct StyledLink<'l> { @@ -16,23 +14,7 @@ pub struct StyledLink<'l> { impl<'l> StyledLink<'l> { #[inline(always)] pub fn render(self) -> Node { - let on_click = { - let href = self.href.to_string(); - mouse_ev("click", move |ev| { - if href.starts_with('/') { - ev.prevent_default(); - ev.stop_propagation(); - if let Ok(url) = seed::Url::from_str(href.as_str()) { - url.go_and_push(); - if let Some(page) = resolve_page(url) { - return Some(Msg::ChangePage(page)); - } - } - } - - None as Option - }) - }; + let on_click = super::events::on_click_change_page(self.href.to_string()); a![ C!["styledLink", self.class_list], diff --git a/web/src/components/styled_md_editor.rs b/web/src/components/styled_md_editor.rs index 357dfe47..26e10341 100644 --- a/web/src/components/styled_md_editor.rs +++ b/web/src/components/styled_md_editor.rs @@ -1,8 +1,8 @@ -use seed::prelude::*; use seed::*; +use seed::prelude::*; -use crate::components::styled_textarea::StyledTextarea; use crate::{FieldChange, FieldId, Msg}; +use crate::components::styled_textarea::StyledTextarea; #[derive(Debug, Clone, PartialOrd, PartialEq, Hash)] #[repr(C)] @@ -84,8 +84,9 @@ impl<'l> StyledMdEditor<'l> { } = self; let id = id.expect("Styled Editor requires ID"); - let on_editor_clicked = click_handler(id.clone(), MdEditorMode::Editor); - let on_view_clicked = click_handler(id.clone(), MdEditorMode::View); + let on_editor_clicked = + super::events::on_click_change_tab(id.clone(), MdEditorMode::Editor); + let on_view_clicked = super::events::on_click_change_tab(id.clone(), MdEditorMode::View); let editor_id = format!("editor-{}", id); let view_id = format!("view-{}", id); @@ -140,11 +141,3 @@ impl<'l> StyledMdEditor<'l> { ] } } - -#[inline(always)] -fn click_handler(field_id: FieldId, new_mode: MdEditorMode) -> EventHandler { - mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - Msg::ModalChanged(FieldChange::TabChanged(field_id, new_mode)) - }) -} diff --git a/web/src/components/styled_modal.rs b/web/src/components/styled_modal.rs index 13aaae2a..41761ba9 100644 --- a/web/src/components/styled_modal.rs +++ b/web/src/components/styled_modal.rs @@ -1,5 +1,5 @@ -use seed::prelude::*; use seed::*; +use seed::prelude::*; use crate::components::styled_icon::{Icon, StyledIcon}; use crate::Msg; @@ -76,19 +76,11 @@ impl<'l> StyledModal<'l> { class_list, } = self; - let close_handler = mouse_ev(Ev::Click, |ev| { - ev.stop_propagation(); - ev.prevent_default(); - Msg::ModalDropped - }); - let body_handler = mouse_ev(Ev::Click, |ev| { - ev.stop_propagation(); - ev.prevent_default(); - None as Option - }); + let close_handler = super::events::on_click_drop_modal(); + let body_handler = super::events::on_click_noop(); let styled_modal_style = match width { - Some(0) => "".to_string(), + Some(0) => String::from(""), Some(n) => format!("max-width: {width}px", width = n), _ => format!("max-width: {width}px", width = 130), }; diff --git a/web/src/components/styled_select.rs b/web/src/components/styled_select.rs index ed724468..ee011135 100644 --- a/web/src/components/styled_select.rs +++ b/web/src/components/styled_select.rs @@ -87,14 +87,15 @@ impl StyledSelectState { self.text_filter = text.clone(); } Msg::StyledSelectChanged(_, StyledSelectChanged::Changed(Some(v))) => { - self.values = vec![*v]; + self.values.clear(); + self.values.push(*v); } Msg::StyledSelectChanged(_, StyledSelectChanged::Changed(None)) => { self.values.clear(); } Msg::StyledSelectChanged(_, StyledSelectChanged::RemoveMulti(v)) => { - let mut old = vec![]; - std::mem::swap(&mut old, &mut self.values); + let len = self.values.len(); + let old = std::mem::replace(&mut self.values, Vec::with_capacity(len)); for u in old { if u != *v { @@ -164,19 +165,10 @@ where clearable, } = self; - let on_text = { - let field_id = id.clone(); - input_ev(Ev::KeyUp, move |value| { - Msg::StyledSelectChanged(field_id, StyledSelectChanged::Text(value)) - }) - }; + let on_text = super::events::on_keyup_change_select_text(id.clone()); - let on_handler = { - let field_id = id.clone(); - mouse_ev(Ev::Click, move |_| { - Msg::StyledSelectChanged(field_id, StyledSelectChanged::DropDownVisibility(!opened)) - }) - }; + let on_handler = + super::events::on_click_change_select_dropdown_visibility(id.clone(), opened); let dropdown_style = dropdown_width.map_or_else( || "width: 100%;".to_string(), @@ -184,14 +176,7 @@ where ); let action_icon = if clearable && !selected.is_empty() { - let on_click = { - let field_id = id.clone(); - mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - Msg::StyledSelectChanged(field_id, StyledSelectChanged::Changed(None)) - }) - }; + let on_click = super::events::on_click_change_select_selected(id.clone(), None); StyledIcon { icon: Icon::Close, class_list: "chevronIcon", @@ -210,26 +195,24 @@ where empty![] }; - let skip = selected.iter().fold(HashMap::new(), |mut h, o| { - h.insert(o.value, true); - h - }); + let skip = { + let len = selected.len(); + selected + .iter() + .fold(HashMap::with_capacity(len), |mut h, o| { + h.insert(o.value, true); + h + }) + }; let children: Vec> = if let Some(options) = options { options .filter(|o| !skip.contains_key(&o.value) && o.match_text(text_filter)) .map(|child| { - let value = child.value(); + let on_change = super::events::on_click_change_select_selected( + id.clone(), + Some(child.value()), + ); let node = child.render_option(); - - let on_change = { - let field_id = id.clone(); - mouse_ev(Ev::Click, move |_| { - Msg::StyledSelectChanged( - field_id, - StyledSelectChanged::Changed(Some(value)), - ) - }) - }; div![C!["option"], on_change, on_handler.clone(), node] }) .collect() @@ -240,10 +223,7 @@ where seed::div![ C!["styledSelect", variant.to_str(), IF![!valid => "invalid"]], attrs![At::Style => dropdown_style.as_str()], - keyboard_ev(Ev::KeyUp, |ev| { - ev.stop_propagation(); - None as Option - }), + super::events::on_keyup_noop(), div![ C!["valueContainer", variant.to_str()], on_handler, @@ -287,18 +267,8 @@ where } fn multi_value(child: StyledSelectOption, id: FieldId) -> Node { - let value = child.value(); + let handler = super::events::on_click_change_select_remove_multi(id, child.value()); - let opt = child.render_multi_value(); - - let handler = { - let field_id = id; - mouse_ev(Ev::Click, move |ev| { - ev.stop_propagation(); - Msg::StyledSelectChanged(field_id, StyledSelectChanged::RemoveMulti(value)) - }) - }; - - div![C!["valueMultiItem"], opt, handler] + div![C!["valueMultiItem"], child.render_multi_value(), handler] } } diff --git a/web/src/components/styled_select_child.rs b/web/src/components/styled_select_child.rs index eed7c421..666d6178 100644 --- a/web/src/components/styled_select_child.rs +++ b/web/src/components/styled_select_child.rs @@ -13,6 +13,7 @@ pub enum DisplayType { SelectMultiValue, } +#[derive(Default)] pub struct StyledSelectOption<'l> { pub name: Option<&'l str>, pub icon: Option>, @@ -22,19 +23,6 @@ pub struct StyledSelectOption<'l> { pub variant: SelectVariant, } -impl<'l> Default for StyledSelectOption<'l> { - fn default() -> Self { - Self { - name: None, - icon: None, - text: None, - value: 0, - class_list: "", - variant: Default::default(), - } - } -} - impl<'l> StyledSelectOption<'l> { #[inline(always)] pub fn value(&self) -> u32 { @@ -75,31 +63,26 @@ impl<'l> StyledSelectOption<'l> { variant, } = self; - let label_node = text.map_or_else( - || Node::Empty, - |text| { - div![ - C![ - variant.to_str(), - name.as_deref() - .map(|s| format!("{}Label", s)) - .unwrap_or_default(), - match display_type { - DisplayType::SelectOption => "optionLabel", - DisplayType::SelectValue | DisplayType::SelectMultiValue => - "selectItemLabel", - }, - class_list - ], - text - ] - }, - ); + let label_node = text.map(|text| { + div![ + C![ + variant.to_str(), + name.map(|s| format!("{}Label", s)), + match display_type { + DisplayType::SelectOption => "optionLabel", + DisplayType::SelectValue | DisplayType::SelectMultiValue => + "selectItemLabel", + }, + class_list + ], + text + ] + }); div![ C![ variant.to_str(), - name.as_deref().unwrap_or_default(), + name, match display_type { DisplayType::SelectOption => "optionItem", DisplayType::SelectValue | DisplayType::SelectMultiValue => "selectItem value", diff --git a/web/src/components/styled_textarea.rs b/web/src/components/styled_textarea.rs index 1e1869a3..e3a7d08d 100644 --- a/web/src/components/styled_textarea.rs +++ b/web/src/components/styled_textarea.rs @@ -49,7 +49,7 @@ impl<'l> StyledTextarea<'l> { disable_auto_resize, } = self; let id = id.expect("Text area requires FieldId"); - let mut style_list = vec![]; + let mut style_list = Vec::with_capacity(3); let min_height = get_min_height(value, height as f64, disable_auto_resize); if min_height > 0f64 { @@ -88,30 +88,11 @@ impl<'l> StyledTextarea<'l> { // }); let handler_disable_auto_resize = disable_auto_resize; - let text_input_handler = { - let id = id.clone(); - ev(update_event, move |event| { - event.stop_propagation(); - - let value = event - .target() - .map(|target| seed::to_textarea(&target).value()) - .unwrap_or_default(); - - if handler_disable_auto_resize && value.contains('\n') { - event.prevent_default(); - } - - Some(Msg::StrInputChanged( - id, - if handler_disable_auto_resize { - value.trim().to_string() - } else { - value - }, - )) - }) - }; + let text_input_handler = super::events::on_event_change_text_value( + id.clone(), + update_event, + handler_disable_auto_resize, + ); div![ id![format!("styledTextArea-{}", id)], diff --git a/web/src/fields.rs b/web/src/fields.rs index 11dfc3cd..9b0a2c06 100644 --- a/web/src/fields.rs +++ b/web/src/fields.rs @@ -5,7 +5,7 @@ use jirs_data::{ pub type AvatarFilterActive = bool; -#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum EditIssueModalSection { Issue(IssueFieldId), Comment(CommentFieldId), @@ -84,7 +84,7 @@ impl ButtonId { } } -#[derive(Clone, Debug, PartialOrd, PartialEq, Hash)] +#[derive(Clone, Copy, Debug, PartialOrd, PartialEq, Hash)] pub enum IssuesAndFiltersId { Jql, } diff --git a/web/src/images/email_send.rs b/web/src/images/email_send.rs new file mode 100644 index 00000000..11bb495d --- /dev/null +++ b/web/src/images/email_send.rs @@ -0,0 +1,16 @@ +use seed::prelude::*; + +pub fn render() -> Node { + use seed::*; + + svg![ + attrs![ + "xmlns"=>"http://www.w3.org/2000/svg", + "viewBox"=>"0 0 64 64", + "xml:space"=>"preserve", + ], + path![attrs![ + At::D=>"m63.94 39.721-3.956-22.388a3.856 3.856 0 0 0-4.464-3.125l-31.726 5.606c-1.97.348-3.308 2.148-3.15 4.102-.022.162 3.983 22.751 3.983 22.751a3.821 3.821 0 0 0 1.586 2.488 3.827 3.827 0 0 0 2.879.636l31.723-5.605a3.825 3.825 0 0 0 2.488-1.586c.59-.843.816-1.865.637-2.879zm-2.672-3.636L50.6 27.879c3.002-3.233 6.137-6.785 7.702-8.572l2.965 16.778zM24.143 21.783l31.724-5.606a1.855 1.855 0 0 1 1.839.772c-.932 1.072-9.375 10.756-13.433 14.56a3.537 3.537 0 0 1-3.918.631c-4.79-2.213-15.22-7.446-17.727-8.707a1.84 1.84 0 0 1 1.515-1.65zm-1.165 4.064c2.35 1.178 6.616 3.31 10.513 5.218l-7.523 11.702-2.99-16.92zm38.686 15.606a1.838 1.838 0 0 1-1.195.763l-31.725 5.607a1.851 1.851 0 0 1-1.386-.308 1.84 1.84 0 0 1-.762-1.195l-.115-.652 8.82-13.72c1.602.777 3.069 1.479 4.215 2.008a5.529 5.529 0 0 0 6.124-.99c1.005-.94 2.255-2.214 3.586-3.62l12.558 9.66.188 1.063c.086.487-.024.98-.308 1.384zM19.008 29.867a.997.997 0 0 0-1.158-.81L.826 32.065a.999.999 0 1 0 .348 1.969l17.023-3.008a.999.999 0 0 0 .81-1.159zM20.03 35.652a.998.998 0 0 0-1.159-.81L8.657 36.645a.999.999 0 1 0 .348 1.969l10.214-1.805a.999.999 0 0 0 .81-1.158zM19.888 40.597 13.079 41.8a.999.999 0 1 0 .348 1.969l6.808-1.203a.999.999 0 0 0 .81-1.158.996.996 0 0 0-1.157-.811z" + ]] + ] +} diff --git a/web/src/images/mod.rs b/web/src/images/mod.rs index 2333a425..d227381c 100644 --- a/web/src/images/mod.rs +++ b/web/src/images/mod.rs @@ -1,2 +1,3 @@ -pub mod project_avatar; +pub mod email_send; pub mod logo; +pub mod project_avatar; diff --git a/web/src/lib.rs b/web/src/lib.rs index 700c77ad..33e9a8b9 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -88,6 +88,7 @@ pub enum Msg { AuthTokenErased, SignInRequest, BindClientRequest, + InvalidPair, // users InviteRequest, diff --git a/web/src/pages/epics_page/view.rs b/web/src/pages/epics_page/view.rs index 2440586f..58b80e04 100644 --- a/web/src/pages/epics_page/view.rs +++ b/web/src/pages/epics_page/view.rs @@ -1,57 +1,49 @@ use chrono::NaiveDateTime; -use jirs_data::{Issue, IssueStatus}; -use seed::prelude::*; use seed::*; +use seed::prelude::*; + +use jirs_data::{Issue, IssueStatus}; use crate::components::styled_icon::{Icon, StyledIcon}; use crate::model::Model; -use crate::shared::inner_layout; use crate::Msg; +use crate::shared::inner_layout; pub fn view(model: &Model) -> Node { let page = crate::match_page!(model, Epics; Empty); - let epics: Vec> = model - .epics - .iter() - .map(|epic| { - let issues = page - .issues(epic.id) - .map(|v| { - v.iter() - .filter_map(|i| model.issues_by_id.get(i)) - .collect::>() + let epics = model.epics.iter().map(|epic| { + let issues = page.issues(epic.id).map(|v| { + v.iter() + .filter_map(|i| model.issues_by_id.get(i)) + .map(|issue| { + render_issue( + issue, + model.issue_statuses_by_id.get(&issue.issue_status_id), + ) }) - .unwrap_or_default(); + .collect::>>() + }); - li![ - C!["epic"], + li![ + C!["epic"], + div![ + C!["firstRow"], + div![C!["epicName"], &epic.name], div![ - C!["firstRow"], - div![C!["epicName"], &epic.name], - div![ - C!["date"], - date_field("Starts at:", "startsAt", epic.starts_at.as_ref()), - date_field("Ends at:", "endsAt", epic.ends_at.as_ref()), - ], - div![C!["counter"], "Number of issues:", issues.len()], + C!["date"], + date_field("Starts at:", "startsAt", epic.starts_at.as_ref()), + date_field("Ends at:", "endsAt", epic.ends_at.as_ref()), ], div![ - C!["secondRow"], - div![ - C!["issues"], - issues - .into_iter() - .map(|issue| render_issue( - issue, - model.issue_statuses_by_id.get(&issue.issue_status_id) - )) - .collect::>>() - ] - ] - ] - }) - .collect(); + C!["counter"], + "Number of issues:", + issues.as_ref().map(Vec::len).unwrap_or(0) + ], + ], + div![C!["secondRow"], div![C!["issues"], issues]] + ] + }); inner_layout( model, @@ -83,12 +75,7 @@ fn render_issue(issue: &Issue, status: Option<&IssueStatus>) -> Node { div![ C!["issue"], div![C!["name"], issue.title.as_str()], - div![ - C!["status"], - status - .map(|status| status.name.as_str()) - .unwrap_or_default() - ], + div![C!["status"], status.map(|s| s.name.as_str())], div![ C!["flags"], div![ diff --git a/web/src/pages/issues_and_filters/view/issue_info.rs b/web/src/pages/issues_and_filters/view/issue_info.rs index 371fbe41..732b4746 100644 --- a/web/src/pages/issues_and_filters/view/issue_info.rs +++ b/web/src/pages/issues_and_filters/view/issue_info.rs @@ -1,6 +1,7 @@ -use jirs_data::{Issue, IssueId}; -use seed::prelude::*; use seed::*; +use seed::prelude::*; + +use jirs_data::{Issue, IssueId}; use crate::components::styled_icon::*; use crate::components::styled_link::*; @@ -163,7 +164,6 @@ fn issue_link(id: IssueId, project_name: &str) -> Node { ], disabled: true, class_list: "withIcon issueLink", - ..Default::default() } .render() } diff --git a/web/src/pages/profile_page/events.rs b/web/src/pages/profile_page/events.rs new file mode 100644 index 00000000..a4f7b2cd --- /dev/null +++ b/web/src/pages/profile_page/events.rs @@ -0,0 +1,12 @@ +use seed::prelude::{ev, Ev, EventHandler}; + +type EvHandler = EventHandler; + +pub fn on_submit_submit_profile() -> EvHandler { + ev(Ev::Submit, |ev| { + ev.prevent_default(); + crate::Msg::PageChanged(crate::PageChanged::Profile( + crate::ProfilePageChange::SubmitForm, + )) + }) +} diff --git a/web/src/pages/profile_page/mod.rs b/web/src/pages/profile_page/mod.rs index c9c79f74..0f87111c 100644 --- a/web/src/pages/profile_page/mod.rs +++ b/web/src/pages/profile_page/mod.rs @@ -2,6 +2,7 @@ pub use model::*; pub use update::*; pub use view::*; +mod events; pub mod model; pub mod update; pub mod view; diff --git a/web/src/pages/profile_page/view.rs b/web/src/pages/profile_page/view.rs index 09ec36f5..4c831596 100644 --- a/web/src/pages/profile_page/view.rs +++ b/web/src/pages/profile_page/view.rs @@ -77,10 +77,7 @@ pub fn view(model: &Model) -> Node { let content = StyledForm { heading: "Profile", - on_submit: Some(ev(Ev::Submit, |ev| { - ev.prevent_default(); - Msg::PageChanged(PageChanged::Profile(ProfilePageChange::SubmitForm)) - })), + on_submit: Some(super::events::on_submit_submit_profile()), fields: vec![ avatar, username_field, @@ -96,18 +93,14 @@ pub fn view(model: &Model) -> Node { fn build_current_project(model: &Model, page: &ProfilePage) -> Node { let inner = if model.projects.len() <= 1 { - let name = model - .project - .as_ref() - .map(|p| p.name.as_str()) - .unwrap_or_default(); + let name = model.project.as_ref().map(|p| p.name.as_str()); span![name] } else { - let mut project_by_id = HashMap::new(); + let mut project_by_id = HashMap::with_capacity(model.projects.len()); for p in model.projects.iter() { project_by_id.insert(p.id, p); } - let mut joined_projects = HashMap::new(); + let mut joined_projects = HashMap::with_capacity(model.user_projects.len()); for p in model.user_projects.iter() { joined_projects.insert(p.project_id, p); } @@ -147,7 +140,7 @@ fn build_current_project(model: &Model, page: &ProfilePage) -> Node { } #[inline(always)] -fn project_select_option<'l>(project: &'l Project) -> StyledSelectOption<'l> { +fn project_select_option(project: &Project) -> StyledSelectOption<'_> { StyledSelectOption { text: Some(project.name.as_str()), value: project.id as u32, @@ -160,12 +153,11 @@ fn editor_mode_select(page: &ProfilePage) -> Node { let time_tracking = StyledCheckbox { options: Some( vec![ - TextEditorMode::MdOnly, - TextEditorMode::RteOnly, - TextEditorMode::Mixed, + editor_mode_checkbox_option(TextEditorMode::MdOnly, &page.text_editor_mode), + editor_mode_checkbox_option(TextEditorMode::RteOnly, &page.text_editor_mode), + editor_mode_checkbox_option(TextEditorMode::Mixed, &page.text_editor_mode), ] - .into_iter() - .map(|tem| editor_mode_checkbox_option(tem, &page.text_editor_mode)), + .into_iter(), ), class_list: "timeTracking", } diff --git a/web/src/pages/project_page/events.rs b/web/src/pages/project_page/events.rs index fcb52b7a..7b759ae4 100644 --- a/web/src/pages/project_page/events.rs +++ b/web/src/pages/project_page/events.rs @@ -1,7 +1,8 @@ +use jirs_data::{EpicId, UserId}; use seed::prelude::*; use crate::model::Page; -use crate::{BoardPageChange, Msg, PageChanged}; +use crate::{AvatarFilterActive, BoardPageChange, Msg, PageChanged}; pub type EvHandler = seed::EventHandler; @@ -72,3 +73,55 @@ pub fn on_click_edit_issue(issue_id: i32) -> EvHandler { Msg::ChangePage(Page::EditIssue(issue_id)) }) } + +pub fn on_click_toggle_only_my() -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ProjectToggleOnlyMy + }) +} + +pub fn on_click_toggle_recent() -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ProjectToggleRecentlyUpdated + }) +} + +pub fn on_click_filter_by_user(user_id: UserId, active: AvatarFilterActive) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ProjectAvatarFilterChanged(user_id, active) + }) +} + +pub fn on_click_clear_filters() -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + Msg::ProjectClearFilters + }) +} + +pub fn on_click_goto_delete_epic(epic_id: EpicId) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + seed::Url::new() + .add_path_part("delete-epic") + .add_path_part(epic_id.to_string()) + .go_and_push(); + Msg::ChangePage(Page::DeleteEpic(epic_id)) + }) +} + +pub fn on_click_goto_edit_epic(epic_id: EpicId) -> EvHandler { + ev(Ev::Click, move |ev| { + ev.stop_propagation(); + ev.prevent_default(); + seed::Url::new() + .add_path_part("edit-epic") + .add_path_part(epic_id.to_string()) + .go_and_push(); + Msg::ChangePage(Page::EditEpic(epic_id)) + }) +} diff --git a/web/src/pages/project_page/view.rs b/web/src/pages/project_page/view.rs index 7bcc672e..d6481277 100644 --- a/web/src/pages/project_page/view.rs +++ b/web/src/pages/project_page/view.rs @@ -26,7 +26,7 @@ fn breadcrumbs(model: &Model) -> Node { C!["breadcrumbsContainer"], span!["Projects"], span![C!["breadcrumbsDivider"], "/"], - span![model.project_name().unwrap_or_default()], + span![model.project_name()], span![C!["breadcrumbsDivider"], "/"], span!["Kanban Board"] ] diff --git a/web/src/pages/project_page/view/board.rs b/web/src/pages/project_page/view/board.rs index 990f8194..4346db56 100644 --- a/web/src/pages/project_page/view/board.rs +++ b/web/src/pages/project_page/view/board.rs @@ -1,13 +1,14 @@ -use jirs_data::*; -use seed::prelude::*; use seed::*; +use seed::prelude::*; +use jirs_data::*; + +use crate::{match_page, Model, Msg}; use crate::components::styled_avatar::*; use crate::components::styled_button::{ButtonVariant, StyledButton}; use crate::components::styled_icon::*; use crate::model::PageContent; use crate::pages::project_page::{events, StatusIssueIds}; -use crate::{match_page, Model, Msg, Page}; #[inline(always)] pub fn project_board_lists(model: &Model) -> Node { @@ -26,30 +27,14 @@ pub fn project_board_lists(model: &Model) -> Node { let edit_button = StyledButton { variant: ButtonVariant::Empty, icon: Some(StyledIcon::from(Icon::EditAlt).render()), - on_click: Some(mouse_ev("click", move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - seed::Url::new() - .add_path_part("edit-epic") - .add_path_part(id.to_string()) - .go_and_push(); - Msg::ChangePage(Page::EditEpic(id)) - })), + on_click: Some(events::on_click_goto_edit_epic(id)), ..Default::default() } .render(); let delete_button = StyledButton { variant: ButtonVariant::Empty, icon: Some(StyledIcon::from(Icon::DeleteAlt).render()), - on_click: Some(mouse_ev("click", move |ev| { - ev.stop_propagation(); - ev.prevent_default(); - seed::Url::new() - .add_path_part("delete-epic") - .add_path_part(id.to_string()) - .go_and_push(); - Msg::ChangePage(Page::DeleteEpic(id)) - })), + on_click: Some(events::on_click_goto_delete_epic(id)), ..Default::default() } .render(); diff --git a/web/src/pages/project_page/view/filters.rs b/web/src/pages/project_page/view/filters.rs index 8b69af85..29597f86 100644 --- a/web/src/pages/project_page/view/filters.rs +++ b/web/src/pages/project_page/view/filters.rs @@ -1,12 +1,14 @@ -use seed::prelude::*; use seed::*; +use seed::prelude::*; +use crate::{FieldId, Model, Msg}; use crate::components::styled_avatar::*; use crate::components::styled_button::*; use crate::components::styled_icon::*; use crate::components::styled_input::*; use crate::model::PageContent; -use crate::{FieldId, Model, Msg}; + +use super::super::events; pub fn project_board_filters(model: &Model) -> Node { let project_page = match &model.page_content { @@ -28,7 +30,7 @@ pub fn project_board_filters(model: &Model) -> Node { active: project_page.only_my_filter, text: Some("Only My Issues"), class_list: "filterChild", - on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleOnlyMy)), + on_click: Some(events::on_click_toggle_only_my()), ..Default::default() } .render(); @@ -37,7 +39,7 @@ pub fn project_board_filters(model: &Model) -> Node { variant: ButtonVariant::Empty, text: Some("Recently Updated"), class_list: "filterChild", - on_click: Some(mouse_ev(Ev::Click, |_| Msg::ProjectToggleRecentlyUpdated)), + on_click: Some(events::on_click_toggle_recent()), ..Default::default() } .render(); @@ -50,7 +52,7 @@ pub fn project_board_filters(model: &Model) -> Node { id!["clearAllFilters"], C!["filterChild"], "Clear all", - mouse_ev(Ev::Click, |_| Msg::ProjectClearFilters), + events::on_click_clear_filters() ] } else { empty![] @@ -72,7 +74,7 @@ pub fn avatars_filters(model: &Model) -> Node { _ => return empty![], }; let active_avatar_filters = &project_page.active_avatar_filters; - let avatars: Vec> = model + let avatars = model .user_ids .iter() .filter_map(|id| model.users_by_id.get(id)) @@ -83,9 +85,7 @@ pub fn avatars_filters(model: &Model) -> Node { let styled_avatar = StyledAvatar { avatar_url: user.avatar_url.as_deref(), name: &user.name, - on_click: Some(mouse_ev(Ev::Click, move |_| { - Msg::ProjectAvatarFilterChanged(user_id, active) - })), + on_click: Some(events::on_click_filter_by_user(user_id, active)), user_index: idx, ..StyledAvatar::default() } @@ -95,8 +95,7 @@ pub fn avatars_filters(model: &Model) -> Node { C!["avatarIsActiveBorder"], styled_avatar ] - }) - .collect(); + }); div![id!["avatars"], C!["filterChild"], avatars] } diff --git a/web/src/pages/project_settings_page/view.rs b/web/src/pages/project_settings_page/view.rs index 105edd71..b206c5da 100644 --- a/web/src/pages/project_settings_page/view.rs +++ b/web/src/pages/project_settings_page/view.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; -use jirs_data::{IssueStatus, ProjectCategory, TimeTracking}; -use seed::prelude::*; use seed::*; +use seed::prelude::*; +use jirs_data::{IssueStatus, ProjectCategory, TimeTracking}; + +use crate::{FieldId, Msg, ProjectFieldId}; use crate::components::styled_button::{ButtonVariant, StyledButton}; use crate::components::styled_checkbox::{ChildBuilder, StyledCheckbox, StyledCheckboxState}; use crate::components::styled_editor::render_styled_editor; @@ -17,7 +19,6 @@ use crate::components::styled_textarea::StyledTextarea; use crate::model::{self, Model, PageContent}; use crate::pages::project_settings_page::{events, ProjectSettingsPage}; use crate::shared::inner_layout; -use crate::{FieldId, Msg, ProjectFieldId}; static TIME_TRACKING_FIBONACCI: &str = include_str!("./time_tracking_fibonacci.txt"); static TIME_TRACKING_HOURLY: &str = include_str!("./time_tracking_hourly.txt"); @@ -246,7 +247,6 @@ fn add_column(page: &ProjectSettingsPage, column_style: &str) -> Node { auto_focus: true, variant: InputVariant::Primary, id: Some(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName)), - input_handlers: vec![], ..Default::default() } .render(); @@ -282,7 +282,6 @@ fn column_preview( valid: page.name.is_valid(), variant: InputVariant::Primary, auto_focus: true, - input_handlers: vec![], id: Some(FieldId::ProjectSettings(ProjectFieldId::IssueStatusName)), ..Default::default() } @@ -300,14 +299,16 @@ fn show_column_preview( per_column_issue_count: &HashMap, column_style: &str, ) -> Node { - let id = is.id; let drag_started = events::on_drag_start_start_drag_column(is.id); let drag_stopped = events::on_drag_end_stop_drag_column(is.id); let drag_over_handler = events::on_drag_over_exchange_position(is.id); let drag_out = events::on_drag_leave_leave_drag_column(is.id); let on_edit = events::on_click_edit_column(is.id); - let issue_count_in_column = per_column_issue_count.get(&id).cloned().unwrap_or_default(); + let issue_count_in_column = per_column_issue_count + .get(&is.id) + .cloned() + .unwrap_or_default(); let delete_row = if issue_count_in_column == 0 { let delete = StyledButton { variant: ButtonVariant::Primary, diff --git a/web/src/pages/reports_page/view.rs b/web/src/pages/reports_page/view.rs index 8ec2ac01..d054f34a 100644 --- a/web/src/pages/reports_page/view.rs +++ b/web/src/pages/reports_page/view.rs @@ -55,7 +55,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node> = vec![]; + let mut svg_parts: Vec> = Vec::with_capacity(40); // each piece is part of column drawable view where number of parts depends on // number of issues which have largest amount of issues @@ -68,7 +68,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node> = vec![]; + let mut legend_parts: Vec> = Vec::with_capacity((resolution + 1) * 2); for y in 0..(resolution + 1) { let current = dominant as f64 * (y as f64 / resolution as f64); @@ -86,7 +86,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node SVG_WIDTH as f64 - (legend_margin_width + SVG_MARGIN_X as f64), At::Height => 1, At::Style => "fill: var(--textLight);", - ],]); + ]]); } svg_parts.push(seed::g![legend_parts]); @@ -115,7 +115,7 @@ fn this_month_graph(page: &ReportsPage, this_month_updated: &[&Issue]) -> Node; + +pub fn on_submit_send_sign_in() -> EvHandler { + send_sign_in(Ev::Submit) +} + +pub fn on_click_send_sign_in() -> EvHandler { + send_sign_in(Ev::Click) +} + +fn send_sign_in(event: Ev) -> EvHandler { + ev(event, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::SignInRequest + }) +} + +pub fn on_submit_bind_client() -> EvHandler { + bind_token(Ev::Submit) +} + +pub fn on_click_bind_client() -> EvHandler { + bind_token(Ev::Click) +} + +fn bind_token(event: Ev) -> EvHandler { + ev(event, |ev| { + ev.stop_propagation(); + ev.prevent_default(); + Msg::BindClientRequest + }) +} diff --git a/web/src/pages/sign_in_page/mod.rs b/web/src/pages/sign_in_page/mod.rs index c9c79f74..0f87111c 100644 --- a/web/src/pages/sign_in_page/mod.rs +++ b/web/src/pages/sign_in_page/mod.rs @@ -2,6 +2,7 @@ pub use model::*; pub use update::*; pub use view::*; +mod events; pub mod model; pub mod update; pub mod view; diff --git a/web/src/pages/sign_in_page/model.rs b/web/src/pages/sign_in_page/model.rs index 24ff7652..bb968c10 100644 --- a/web/src/pages/sign_in_page/model.rs +++ b/web/src/pages/sign_in_page/model.rs @@ -9,15 +9,30 @@ pub type UsernameValidator = Touched>; pub type EmailValidator = Touched>, Changed>>; pub type TokenValidator = Touched, Changed>>; +#[derive(Debug, PartialOrd, PartialEq)] +pub enum SignInState { + Initial, + RequestSend, + EmailSend, + InvalidPair, +} + +impl Default for SignInState { + fn default() -> Self { + Self::Initial + } +} + #[derive(Debug, Default)] pub struct SignInPage { pub username: String, pub email: String, pub token: String, - pub login_success: bool, pub bad_token: String, // validators pub username_v: UsernameValidator, pub email_v: EmailValidator, pub token_v: TokenValidator, + + pub state: SignInState, } diff --git a/web/src/pages/sign_in_page/update.rs b/web/src/pages/sign_in_page/update.rs index 7c54ffed..fec5d0ce 100644 --- a/web/src/pages/sign_in_page/update.rs +++ b/web/src/pages/sign_in_page/update.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use jirs_data::msg::WsMsgSession; +use jirs_data::msg::{WsError, WsMsgSession}; use jirs_data::{SignInFieldId, WsMsg}; use seed::prelude::*; use seed::*; @@ -8,6 +8,7 @@ use uuid::Uuid; use crate::model::{self, Model, Page, PageContent}; use crate::pages::sign_in_page::model::SignInPage; +use crate::pages::sign_in_page::SignInState; use crate::shared::validate::*; use crate::shared::write_auth_token; use crate::ws::send_ws_msg; @@ -39,11 +40,16 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) page.token = value; } Msg::SignInRequest => { + if page.email.is_empty() || page.username.is_empty() { + return; + } + send_ws_msg( WsMsgSession::AuthenticateRequest(page.email.clone(), page.username.clone()).into(), model.ws.as_ref(), orders, ); + page.state = SignInState::RequestSend; } Msg::BindClientRequest => { let bind_token: uuid::Uuid = match Uuid::from_str(page.token.as_str()) { @@ -59,9 +65,15 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) orders, ); } + Msg::InvalidPair => { + page.state = SignInState::InvalidPair; + } Msg::WebSocketChange(change) => match change { WebSocketChanged::WsMsg(WsMsg::Session(WsMsgSession::AuthenticateSuccess)) => { - page.login_success = true; + page.state = SignInState::EmailSend; + } + WebSocketChanged::WsMsg(WsMsg::Error(WsError::InvalidPair(_, _))) => { + page.state = SignInState::EmailSend; } WebSocketChanged::WsMsg(WsMsg::Session(WsMsgSession::BindTokenOk(access_token))) => { match write_auth_token(Some(access_token)) { diff --git a/web/src/pages/sign_in_page/view.rs b/web/src/pages/sign_in_page/view.rs index d87ece7b..7f3a4ada 100644 --- a/web/src/pages/sign_in_page/view.rs +++ b/web/src/pages/sign_in_page/view.rs @@ -7,6 +7,7 @@ use crate::components::styled_form::StyledForm; use crate::components::styled_icon::{Icon, StyledIcon}; use crate::components::styled_input::StyledInput; use crate::components::styled_link::StyledLink; +use crate::pages::sign_in_page::{events, SignInPage, SignInState}; use crate::shared::outer_layout; use crate::shared::validate::Validator; use crate::{match_page, model, FieldId, Msg, SignInFieldId}; @@ -14,47 +15,107 @@ use crate::{match_page, model, FieldId, Msg, SignInFieldId}; pub fn view(model: &model::Model) -> Node { let page = match_page!(model, SignIn; Empty); - let username = StyledInput { - value: page.username.as_str(), - valid: page.username_v.is_valid(), - id: Some(FieldId::SignIn(SignInFieldId::Username)), - err_msg: page.username_v.message(), - ..Default::default() - } - .render(); - let username_field = StyledField { - label: "Username", - input: username, + let sign_in_form = if page.state == SignInState::EmailSend { + StyledForm { + fields: vec![StyledField { + input: div![ + C!["emailSend"], + div![crate::images::email_send::render()], + div!["E-Mail send"] + ], + ..Default::default() + } + .render()], + ..Default::default() + } + .render() + } else { + StyledForm { + heading: "Sign In to your account", + fields: vec![ + username_field(page), + email_field(page), + submit_field(page), + if page.state == SignInState::InvalidPair { + div![C!["error"], p!["Invalid credentials"]] + } else { + Node::Empty + }, + no_pass_section(), + ], + on_submit: Some(events::on_submit_send_sign_in()), + } + .render() + }; + + let bind_token_form = StyledForm { + on_submit: Some(events::on_submit_bind_client()), + fields: vec![token_field(page), submit_token_field()], ..Default::default() } .render(); - let email = StyledInput { - value: page.email.as_str(), - valid: page.email_v.is_valid(), - id: Some(FieldId::SignIn(SignInFieldId::Email)), - err_msg: page.email_v.message(), - ..Default::default() - } - .render(); - let email_field = StyledField { - label: "E-Mail", - input: email, - ..Default::default() - } - .render(); + outer_layout(model, "login", vec![sign_in_form, bind_token_form]) +} - let submit = if page.login_success { +fn submit_token_field() -> Node { + StyledField { + input: StyledButton { + variant: ButtonVariant::Primary, + text: Some("Authorize"), + on_click: Some(events::on_click_bind_client()), + ..Default::default() + } + .render(), + ..Default::default() + } + .render() +} + +fn token_field(page: &SignInPage) -> Node { + StyledField { + label: "Single use token", + input: StyledInput { + id: Some(FieldId::SignIn(SignInFieldId::Token)), + valid: page.token_v.is_valid(), + value: page.token.as_str(), + err_msg: page.token_v.message(), + ..Default::default() + } + .render(), + ..Default::default() + } + .render() +} + +fn no_pass_section() -> Node { + let help_icon = StyledIcon { + icon: Icon::Help, + class_list: "noPasswordHelp", + size: Some(22), + ..Default::default() + } + .render(); + div![ + C!["noPasswordSection"], + attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."], + help_icon, + span!["Why I don't see password?"] + ] +} + +fn submit_field(page: &SignInPage) -> Node { + let submit = if page.state == SignInState::RequestSend { StyledButton { variant: ButtonVariant::Success, - text: Some("✓ Please check your mail"), + text: Some("Checking..."), ..Default::default() } } else { StyledButton { variant: ButtonVariant::Primary, text: Some("Sign In"), - on_click: Some(mouse_ev(Ev::Click, |_| Msg::SignInRequest)), + on_click: Some(events::on_click_send_sign_in()), ..Default::default() } } @@ -66,76 +127,41 @@ pub fn view(model: &model::Model) -> Node { ..Default::default() } .render(); - let submit_field = StyledField { + StyledField { input: div![C!["twoRow"], submit, register_link], ..Default::default() } - .render(); - - let help_icon = StyledIcon { - icon: Icon::Help, - class_list: "noPasswordHelp", - size: Some(22), - ..Default::default() - } - .render(); - - let no_pass_section = div![ - C!["noPasswordSection"], - attrs![At::Title => "We don't believe password is helping anyone. Instead after user provide correct login and e-mail he'll receive mail with 1-use token."], - help_icon, - span!["Why I don't see password?"] - ]; - - let sign_in_form = StyledForm { - heading: "Sign In to your account", - fields: vec![username_field, email_field, submit_field, no_pass_section], - on_submit: Some(ev(Ev::Submit, |ev| { - ev.stop_propagation(); - ev.prevent_default(); - Msg::SignInRequest - })), - } - .render(); - - let token = StyledInput { - id: Some(FieldId::SignIn(SignInFieldId::Token)), - valid: page.token_v.is_valid(), - value: page.token.as_str(), - err_msg: page.token_v.message(), - ..Default::default() - } - .render(); - let token_field = StyledField { - label: "Single use token", - input: token, - ..Default::default() - } - .render(); - let submit_token = StyledButton { - variant: ButtonVariant::Primary, - text: Some("Authorize"), - on_click: Some(mouse_ev(Ev::Click, |_| Msg::BindClientRequest)), - ..Default::default() - } - .render(); - let submit_token_field = StyledField { - input: submit_token, - ..Default::default() - } - .render(); - - let bind_token_form = StyledForm { - on_submit: Some(ev(Ev::Submit, |ev| { - ev.stop_propagation(); - ev.prevent_default(); - Msg::BindClientRequest - })), - fields: vec![token_field, submit_token_field], - ..Default::default() - } - .render(); - - let children = vec![sign_in_form, bind_token_form]; - outer_layout(model, "login", children) + .render() +} + +fn username_field(page: &SignInPage) -> Node { + StyledField { + label: "Username", + input: StyledInput { + value: page.username.as_str(), + valid: page.username_v.is_valid(), + id: Some(FieldId::SignIn(SignInFieldId::Username)), + err_msg: page.username_v.message(), + ..Default::default() + } + .render(), + ..Default::default() + } + .render() +} + +fn email_field(page: &SignInPage) -> Node { + StyledField { + label: "E-Mail", + input: StyledInput { + value: page.email.as_str(), + valid: page.email_v.is_valid(), + id: Some(FieldId::SignIn(SignInFieldId::Email)), + err_msg: page.email_v.message(), + ..Default::default() + } + .render(), + ..Default::default() + } + .render() } diff --git a/web/src/pages/sign_up_page/update.rs b/web/src/pages/sign_up_page/update.rs index 14ab6cc0..81a619ab 100644 --- a/web/src/pages/sign_up_page/update.rs +++ b/web/src/pages/sign_up_page/update.rs @@ -32,6 +32,10 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) page.email_touched = true; } Msg::SignUpRequest => { + if page.email.is_empty() || page.username.is_empty() { + return; + } + send_ws_msg( WsMsgSession::SignUpRequest(page.email.clone(), page.username.clone()).into(), model.ws.as_ref(), @@ -47,6 +51,9 @@ pub fn update(msg: Msg, model: &mut model::Model, orders: &mut impl Orders) } _ => (), }, + Msg::InvalidPair => { + page.error = String::from("Pair already taken"); + } _ => (), } } diff --git a/web/src/pages/sign_up_page/view.rs b/web/src/pages/sign_up_page/view.rs index a42b3d9d..db0079ff 100644 --- a/web/src/pages/sign_up_page/view.rs +++ b/web/src/pages/sign_up_page/view.rs @@ -15,30 +15,28 @@ use crate::{match_page, model, FieldId, Msg}; pub fn view(model: &model::Model) -> Node { let page = match_page!(model, SignUp; Empty); - let username = StyledInput { - value: page.username.as_str(), - valid: !page.username_touched || page.username.len() > 1, - id: Some(FieldId::SignUp(SignUpFieldId::Username)), - ..Default::default() - } - .render(); let username_field = StyledField { label: "Username", - input: username, + input: StyledInput { + value: page.username.as_str(), + valid: !page.username_touched || page.username.len() > 1, + id: Some(FieldId::SignUp(SignUpFieldId::Username)), + ..Default::default() + } + .render(), ..Default::default() } .render(); - let email = StyledInput { - value: page.email.as_str(), - valid: !page.email_touched || is_email(page.email.as_str()), - id: Some(FieldId::SignUp(SignUpFieldId::Email)), - ..Default::default() - } - .render(); let email_field = StyledField { label: "E-Mail", - input: email, + input: StyledInput { + value: page.email.as_str(), + valid: !page.email_touched || is_email(page.email.as_str()), + id: Some(FieldId::SignUp(SignUpFieldId::Email)), + ..Default::default() + } + .render(), ..Default::default() } .render(); @@ -59,16 +57,18 @@ pub fn view(model: &model::Model) -> Node { } .render(); - let sign_in_link = StyledLink { - children: vec![span!["Sign In"]], - class_list: "signInLink", - href: "/login", - ..Default::default() - } - .render(); - let submit_field = StyledField { - input: div![C!["twoRow"], submit, sign_in_link], + input: div![ + C!["twoRow"], + submit, + StyledLink { + children: vec![span!["Sign In"]], + class_list: "signInLink", + href: "/login", + ..Default::default() + } + .render() + ], ..Default::default() } .render(); diff --git a/web/src/ws/mod.rs b/web/src/ws/mod.rs index 4822264a..18e37948 100644 --- a/web/src/ws/mod.rs +++ b/web/src/ws/mod.rs @@ -1,12 +1,13 @@ pub use init_load_sets::*; use jirs_data::msg::{ - WsMsgComment, WsMsgEpic, WsMsgIssue, WsMsgIssueStatus, WsMsgMessage, WsMsgProject, + WsError, WsMsgComment, WsMsgEpic, WsMsgIssue, WsMsgIssueStatus, WsMsgMessage, WsMsgProject, WsMsgSession, WsMsgUser, }; use jirs_data::*; use seed::prelude::*; use crate::model::*; +use crate::pages::sign_in_page::SignInState; use crate::shared::{go_to_board, write_auth_token}; use crate::{Msg, OperationKind, ResourceKind, WebSocketChanged}; @@ -432,7 +433,7 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders) if let Some(idx) = model.epics.iter().position(|e| e.id == *id) { model.epics.remove(idx); } - model.epics_by_id.remove(&id); + model.epics_by_id.remove(id); model.epics.sort_by(|a, b| a.id.cmp(&b.id)); orders.send_msg(Msg::ResourceChanged( ResourceKind::Epic, @@ -442,7 +443,10 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders) } WsMsg::Session(WsMsgSession::AuthenticateSuccess) => { let page = crate::match_page_mut!(model, SignIn); - page.login_success = true; + page.state = SignInState::EmailSend; + } + WsMsg::Error(WsError::InvalidLoginPair) => { + orders.send_msg(Msg::InvalidPair); } WsMsg::Session(WsMsgSession::BindTokenOk(access_token)) => { match write_auth_token(Some(*access_token)) { @@ -454,6 +458,9 @@ pub fn update(msg: &mut WsMsg, model: &mut Model, orders: &mut impl Orders) } } } + WsMsg::Session(WsMsgSession::SignUpPairTaken) => { + orders.send_msg(Msg::InvalidPair); + } _ => { log::info!( "got web socket message but don't know what to do with it {:?}",